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:")