package tui import ( "context" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "time" "albedo/cli/internal/daemon" "albedo/cli/internal/webui" tea "charm.land/bubbletea/v2" ) type artifactOpenResult struct { Notice string Copied string Err error } // artifactRow renders one selectable artifact activity consistently in transcript and cards. func artifactRow(activity daemon.ToolActivity, width int) string { if activity.Artifact == nil { return "" } label := "↗ open artifact albedo made for you: " + activity.Target if activity.Artifact.Version > 1 { label = fmt.Sprintf("↗ open artifact albedo updated: %s · v%d", activity.Target, activity.Artifact.Version) } key := activity.Artifact.ID + "." + strconv.FormatInt(activity.Artifact.Version, 10) tail := DefaultStyles.Faint.Render(" · ") + DefaultStyles.Muted.Render("⧉") return fit(rowAction{verb: verbArtifact, key: key}.mark()+DefaultStyles.Busy.Render(label), tail, width) } // openArtifactAction opens or copies the version a row names. The first // version's row names none, so it uses the address that follows the newest, // and a tab reloads itself as the agent revises the page. func (m ChatModel) openArtifactAction(key string, copyURL bool) tea.Cmd { id, versionText, ok := strings.Cut(key, ".") version, err := strconv.ParseInt(versionText, 10, 64) if !ok || id == "" || err != nil || version < 1 || m.artifactConn == nil { return func() tea.Msg { return artifactOpenResult{Err: errors.New("artifact details are unavailable")} } } return openArtifactCmd(m.artifactConn, m.openBrowser, id, version, copyURL) } func openArtifactCmd(conn *daemon.Connection, openBrowser func(string), id string, version int64, copyURL bool) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) defer cancel() artifact, err := daemon.GetArtifact(ctx, conn, id) if err != nil { var api *daemon.APIError if errors.As(err, &api) && api.StatusCode == http.StatusNotFound { return artifactOpenResult{Err: errors.New("this artifact was deleted")} } return artifactOpenResult{Err: fmt.Errorf("could not read artifact: %w", err)} } key := artifact.Key if key == "" { return artifactOpenResult{Err: errors.New("artifact has no page key")} } base, err := findOrStartWeb(ctx) if err != nil { return artifactOpenResult{Err: err} } shown := "latest" if version > 1 { shown = strconv.FormatInt(version, 10) } target := strings.TrimRight(base, "/") + "/frame/" + id + "." + key + "/" + shown + "/" if copyURL { return artifactOpenResult{Copied: target} } if openBrowser != nil { openBrowser(target) } return artifactOpenResult{Notice: "opened " + artifact.Title + " in your browser"} } } func findOrStartWeb(ctx context.Context) (string, error) { path := webui.ManifestPath() if manifest, err := webui.ReadManifest(path); err == nil && webui.ManifestAlive(manifest) { return manifest.URL, nil } executable, err := os.Executable() if err != nil { return "", fmt.Errorf("cannot find albedo web: %w", err) } return startWeb(ctx, executable, path) } // startWeb starts albedo web on its usual port, so the link people know stays // put. If that child dies at once (the port is taken) any free port will do: // artifacts open on whatever server runs. func startWeb(ctx context.Context, executable, path string) (string, error) { base, err := launchWeb(ctx, executable, path) if err == nil || ctx.Err() != nil { return base, err } return launchWeb(ctx, executable, path, "--port", "0") } func launchWeb(ctx context.Context, executable, path string, extra ...string) (string, error) { logPath := filepath.Join(filepath.Dir(path), "web.log") log, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { return "", fmt.Errorf("cannot start albedo web: %w", err) } command := exec.Command(executable, append([]string{"web", "--no-open"}, extra...)...) command.Stdout, command.Stderr, command.Env = log, log, os.Environ() command.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := command.Start(); err != nil { log.Close() return "", fmt.Errorf("cannot start albedo web: %w", err) } _ = log.Close() exited := make(chan error, 1) go func() { exited <- command.Wait() }() // also reaps it when it stops ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return "", errors.New("albedo web did not start within 8 seconds") case err := <-exited: if err != nil { return "", webFailure(logPath, err) } exited = nil // it started its background server and left case <-ticker.C: if manifest, err := webui.ReadManifest(path); err == nil && webui.ManifestAlive(manifest) { return manifest.URL, nil } } } } // webFailure is the last line the server logged, which says why it stopped. func webFailure(logPath string, err error) error { output, _ := os.ReadFile(logPath) text := strings.TrimSpace(string(output)) if text == "" { text = err.Error() } return errors.New(text[strings.LastIndex(text, "\n")+1:]) }