all: support popping a browser from termui

- Add 'browser', 'open', and 'b' command aliases to termui
- Open the current conversation URL in default browser
- Add help documentation for the new command

Add browser launch endpoint to Git server for Docker support.

We'll probably want to set up a proper mux for the no-longer-just-git
server pretty soon.

Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/dockerimg/dockerimg.go b/dockerimg/dockerimg.go
index baaf506..555b87e 100644
--- a/dockerimg/dockerimg.go
+++ b/dockerimg/dockerimg.go
@@ -396,9 +396,14 @@
 	}
 	ret.gitLn = gitLn
 
-	srv := http.Server{
-		Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass)},
-	}
+	browserC := make(chan string, 1) // channel of URLs to open in browser
+	go func() {
+		for url := range browserC {
+			browser.Open(url)
+		}
+	}()
+
+	srv := http.Server{Handler: &gitHTTP{gitRepoRoot: gitRoot, pass: []byte(ret.pass), browserC: browserC}}
 	ret.srv = &srv
 
 	_, gitPort, err := net.SplitHostPort(gitLn.Addr().String())
@@ -551,6 +556,7 @@
 	initMsg, err := json.Marshal(
 		server.InitRequest{
 			Commit:            commit,
+			OutsideHTTP:       fmt.Sprintf("http://sketch:%s@host.docker.internal:%s", gitPass, gitPort),
 			GitRemoteAddr:     fmt.Sprintf("http://sketch:%s@host.docker.internal:%s/.git", gitPass, gitPort),
 			HostAddr:          localAddr,
 			SSHAuthorizedKeys: sshAuthorizedKeys,
diff --git a/dockerimg/githttp.go b/dockerimg/githttp.go
index 38a8b54..6270838 100644
--- a/dockerimg/githttp.go
+++ b/dockerimg/githttp.go
@@ -3,6 +3,7 @@
 import (
 	"crypto/subtle"
 	"fmt"
+	"io"
 	"log/slog"
 	"net/http"
 	"net/http/cgi"
@@ -14,6 +15,7 @@
 type gitHTTP struct {
 	gitRepoRoot string
 	pass        []byte
+	browserC    chan string // browser launch requests
 }
 
 func (g *gitHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) {
@@ -46,6 +48,33 @@
 		return
 	}
 
+	// TODO: real mux?
+	if strings.HasPrefix(r.URL.Path, "/browser") {
+		if r.Method != http.MethodPost {
+			http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+			return
+		}
+		body, err := io.ReadAll(r.Body)
+		if err != nil {
+			http.Error(w, "Failed to read request body: "+err.Error(), http.StatusBadRequest)
+			return
+		}
+		defer r.Body.Close()
+		url := strings.TrimSpace(string(body))
+		if len(url) == 0 {
+			http.Error(w, "URL cannot be empty", http.StatusBadRequest)
+			return
+		}
+		select {
+		case g.browserC <- string(url):
+			slog.InfoContext(r.Context(), "open browser", "url", url)
+			w.WriteHeader(http.StatusOK)
+		default:
+			http.Error(w, "Too many browser launch requests", http.StatusTooManyRequests)
+		}
+		return
+	}
+
 	if runtime.GOOS == "darwin" {
 		// On the Mac, Docker connections show up from localhost. On Linux, the docker
 		// network is more arbitrary, so we don't do this additional check there.
diff --git a/loop/agent.go b/loop/agent.go
index b12a5f5..cc861ed 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -6,6 +6,7 @@
 	_ "embed"
 	"encoding/json"
 	"fmt"
+	"io"
 	"log/slog"
 	"net/http"
 	"os"
@@ -17,6 +18,7 @@
 	"time"
 
 	"sketch.dev/ant"
+	"sketch.dev/browser"
 	"sketch.dev/claudetool"
 	"sketch.dev/claudetool/bashkit"
 )
@@ -92,6 +94,8 @@
 	OutsideHostname() string
 	OutsideWorkingDir() string
 	GitOrigin() string
+	// OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
+	OpenBrowser(url string)
 
 	// RestartConversation resets the conversation history
 	RestartConversation(ctx context.Context, rev string, initialPrompt string) error
@@ -274,6 +278,7 @@
 	lastHEAD          string        // hash of the last HEAD that was pushed to the host (only when under docker)
 	initialCommit     string        // hash of the Git HEAD when the agent was instantiated or Init()
 	gitRemoteAddr     string        // HTTP URL of the host git repo (only when under docker)
+	outsideHTTP       string        // base address of the outside webserver (only when under docker)
 	ready             chan struct{} // closed when the agent is initialized (only when under docker)
 	startedAt         time.Time
 	originalBudget    ant.Budget
@@ -393,6 +398,27 @@
 	return a.gitOrigin
 }
 
+func (a *Agent) OpenBrowser(url string) {
+	if !a.IsInContainer() {
+		browser.Open(url)
+		return
+	}
+	// We're in Docker, need to send a request to the Git server
+	// to signal that the outer process should open the browser.
+	httpc := &http.Client{Timeout: 5 * time.Second}
+	resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", strings.NewReader(url))
+	if err != nil {
+		slog.Debug("browser launch request connection failed", "err", err, "url", url)
+		return
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode == http.StatusOK {
+		return
+	}
+	body, _ := io.ReadAll(resp.Body)
+	slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
+}
+
 // CurrentState returns the current state of the agent's state machine.
 func (a *Agent) CurrentState() State {
 	return a.stateMachine.CurrentState()
@@ -595,6 +621,7 @@
 
 	InDocker      bool
 	Commit        string
+	OutsideHTTP   string
 	GitRemoteAddr string
 	HostAddr      string
 }
@@ -627,6 +654,7 @@
 		}
 		a.lastHEAD = ini.Commit
 		a.gitRemoteAddr = ini.GitRemoteAddr
+		a.outsideHTTP = ini.OutsideHTTP
 		a.initialCommit = ini.Commit
 		if ini.HostAddr != "" {
 			a.url = "http://" + ini.HostAddr
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index c19f806..82846ac 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -77,6 +77,7 @@
 
 type InitRequest struct {
 	HostAddr          string `json:"host_addr"`
+	OutsideHTTP       string `json:"outside_http"`
 	GitRemoteAddr     string `json:"git_remote_addr"`
 	Commit            string `json:"commit"`
 	SSHAuthorizedKeys []byte `json:"ssh_authorized_keys"`
@@ -197,6 +198,7 @@
 			WorkingDir:    "/app",
 			InDocker:      true,
 			Commit:        m.Commit,
+			OutsideHTTP:   m.OutsideHTTP,
 			GitRemoteAddr: m.GitRemoteAddr,
 			HostAddr:      m.HostAddr,
 		}
diff --git a/termui/termui.go b/termui/termui.go
index 40e76b7..e6e5b4e 100644
--- a/termui/termui.go
+++ b/termui/termui.go
@@ -195,6 +195,7 @@
 - help, ?             : Show this help message
 - budget              : Show original budget
 - usage, cost         : Show current token usage and cost
+- browser, open, b    : Open current conversation in browser
 - stop, cancel, abort : Cancel the current operation
 - exit, quit, q       : Exit sketch
 - ! <command>         : Execute a shell command (e.g. !ls -la)`)
@@ -208,6 +209,13 @@
 				ui.AppendSystemMessage("- Max wall time: %v", originalBudget.MaxWallTime)
 			}
 			ui.AppendSystemMessage("- Max total cost: %0.2f", originalBudget.MaxDollars)
+		case "browser", "open", "b":
+			if ui.httpURL != "" {
+				ui.AppendSystemMessage("🌐 Opening %s in browser", ui.httpURL)
+				go ui.agent.OpenBrowser(ui.httpURL)
+			} else {
+				ui.AppendSystemMessage("❌ No web URL available for this session")
+			}
 		case "usage", "cost":
 			totalUsage := ui.agent.TotalUsage()
 			ui.AppendSystemMessage("💰 Current usage summary:")