diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9513809..085fc24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@v6.4.0 with: go-version: "1.25.x" @@ -39,6 +41,8 @@ jobs: goarch: amd64 steps: - uses: actions/checkout@v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@v6.4.0 with: go-version: "1.25.x" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 851a8c0..0e3ef95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,8 @@ jobs: goarch: amd64 steps: - uses: actions/checkout@v7.0.0 + with: + persist-credentials: false - uses: actions/setup-go@v6.4.0 with: go-version: "1.25.x" @@ -65,6 +67,8 @@ jobs: needs: build steps: - uses: actions/checkout@v7.0.0 + with: + persist-credentials: false - uses: actions/download-artifact@v8.0.1 with: path: dist diff --git a/README.md b/README.md index 5a081a1..5640eb5 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ showagent # open the interactive picker showagent list # plain table of every session showagent list --json # the same, machine-readable showagent resume latest # reopen the most recent session, any agent +showagent convert latest --to claude --dry-run + # preview exactly what a hand-off would carry/drop +showagent info latest # exact resume command + storage location +showagent update # install the latest GitHub release showagent --help # full CLI help ``` @@ -97,7 +101,7 @@ showagent --help # full CLI help | `space` | Collapse or expand the selected workspace group | | `o` | Cycle the convert target for the selected session | | `t` | Cycle the convert scope: all turns, or latest 200/100/50/20/10 | -| `x` | Convert to the target agent and select the new session | +| `x` | Preview convert; press `x` again to write and select the new session | | `n` | Branch: create a full local copy of the session | | `y` | Toggle yolo resume (skip the agent's permission prompts) | | `C` | Compound: resume with a learnings-capture prompt (see below) | @@ -126,9 +130,20 @@ are a stable contract: ``` `showagent resume [--yolo]` resumes without the picker, so a shell -alias can reopen your last session in one keystroke. Exit codes: `0` success, -`1` error (including "no sessions found"), `2` usage. When stdout is not a -terminal, plain `showagent` prints the `list` table, so pipes just work. +alias can reopen your last session in one keystroke. + +`showagent convert --to --dry-run` prints the hand-off +before writing anything: source session, target provider, workspace, scope, +transferable turn count, last user ask, and the agent-specific state that will +be dropped. Remove `--dry-run` to write the converted session, then showagent +prints the resume recipe for the new row. + +`showagent info [--yolo]` prints the exact resume command, +working directory, and storage location for a session. + +Exit codes: `0` success, `1` error (including "no sessions found"), `2` usage. +When stdout is not a terminal, plain `showagent` prints the `list` table, so +pipes just work. ## How it compares @@ -162,10 +177,12 @@ only installs what is missing. ## FAQ **Is my session data sent anywhere?** -No. showagent is fully local: it reads session files where the agents left -them and makes no network calls. There is no server, no telemetry, and no -account. Message previews additionally redact password-like strings and -API keys before rendering (covered by tests in +No session content leaves your machine. showagent reads session files where the +agents left them; there is no server, no telemetry, and no account. The only +network path is the optional release updater: `showagent update`, and the +startup "update available?" check for release builds (disable with +`SHOWAGENT_NO_UPDATE_CHECK=1`). Message previews additionally redact +password-like strings and API keys before rendering (covered by tests in [`internal/session/session_test.go`](internal/session/session_test.go)). Release archives ship with a `SHA256SUMS` file, and releases after v0.7.0 also carry GitHub build provenance — verify with @@ -176,11 +193,15 @@ Conversion extracts the user and assistant turns from the source transcript and writes a brand-new session in the target agent's native format (for OpenCode, via `opencode import`), so the target's own resume command picks it up. The original session is never modified, and files are written atomically — -a crash cannot leave a half-written session in another tool's store. It -intentionally does **not** copy tool-call internals, approval history, -encrypted reasoning blobs, or provider attachments: those are private to the -source agent and would not replay correctly anyway. `t` trims the scope to the -latest N turns before converting. +a crash cannot leave a half-written session in another tool's store. + +Trust is explicit: in the TUI, the first `x` shows the hand-off preview and the +second `x` writes it. In scripts, use `showagent convert ... --dry-run` for +the same preview. Conversion intentionally does **not** copy tool-call +internals, approval history, encrypted reasoning blobs, or provider +attachments: those are private to the source agent and would not replay +correctly anyway. `t` / `--scope` trims the scope to the latest N turns before +converting. **What does delete actually do?** Codex sessions are deleted through `codex delete --force`; OpenCode through diff --git a/cmd/showagent/main.go b/cmd/showagent/main.go index be02d79..11c8fcc 100644 --- a/cmd/showagent/main.go +++ b/cmd/showagent/main.go @@ -6,6 +6,8 @@ import ( "io" "os" "runtime/debug" + "strconv" + "strings" "time" "github.com/charmbracelet/x/term" @@ -17,7 +19,7 @@ import ( // version is stamped by the release build via -ldflags "-X main.version=...". var version = "dev" -const usageLine = "usage: showagent [list [--json] | resume [--yolo] | setup]" +const usageLine = "usage: showagent [list [--json] | resume [--yolo] | convert --to [--dry-run] | info | update | setup]" func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) @@ -41,6 +43,12 @@ func run(args []string, stdout, stderr io.Writer) int { return runList(args[1:], stdout, stderr) case "resume": return runResume(args[1:], stderr) + case "convert": + return runConvert(args[1:], stdout, stderr) + case "info": + return runInfo(args[1:], stdout, stderr) + case "update": + return runUpdate(args[1:], stdout, stderr) case "setup": if len(args) != 1 { return usageError(stderr, fmt.Sprintf("setup takes no arguments, got %q", args[1])) @@ -63,6 +71,10 @@ func runDefault(stdout, stderr io.Writer) int { return 0 } + if code, updated := maybePromptForUpdate(os.Stdin, stderr); updated { + return code + } + selection, err := tui.Run() if err != nil { _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) @@ -152,7 +164,7 @@ func printNoSessions(stderr io.Writer) int { } _, _ = fmt.Fprintln(stderr, line) } - _, _ = fmt.Fprintln(stderr, "start a conversation with codex or claude, then run showagent again") + _, _ = fmt.Fprintln(stderr, "start a conversation with a supported agent (codex, claude, gemini, opencode, jcode), then run showagent again") return 1 } @@ -185,6 +197,109 @@ func runResume(args []string, stderr io.Writer) int { return 0 } +func runConvert(args []string, stdout, stderr io.Writer) int { + id := "" + targetName := "" + dryRun := false + handoff := session.HandoffOptions{} + resumeOptions := session.ResumeOptions{} + + for index := 0; index < len(args); index++ { + arg := args[index] + switch arg { + case "--to": + index++ + if index >= len(args) { + return usageError(stderr, "convert --to needs a provider") + } + targetName = args[index] + case "--dry-run": + dryRun = true + case "--scope": + index++ + if index >= len(args) { + return usageError(stderr, "convert --scope needs a value") + } + parsed, err := parseHandoffScope(args[index]) + if err != nil { + return usageError(stderr, err.Error()) + } + handoff = parsed + case "--yolo": + resumeOptions.Dangerous = true + default: + if strings.HasPrefix(arg, "-") { + return usageError(stderr, fmt.Sprintf("unknown convert argument %q", arg)) + } + if id != "" { + return usageError(stderr, fmt.Sprintf("unexpected convert argument %q", arg)) + } + id = arg + } + } + if id == "" { + return usageError(stderr, "convert needs a session id or 'latest'") + } + if targetName == "" { + return usageError(stderr, "convert needs --to ") + } + target, err := session.ParseProvider(targetName) + if err != nil { + return usageError(stderr, err.Error()) + } + + row, err := resolveSession(session.Discover(), id) + if err != nil { + _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) + return 1 + } + preview, err := session.PreviewConversion(row, target, handoff) + if err != nil { + _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) + return 1 + } + if dryRun { + printConversionPreview(stdout, preview) + return 0 + } + + converted, err := session.Convert(row, target, handoff) + if err != nil { + _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) + return 1 + } + _, _ = fmt.Fprintf(stdout, "converted %s %s -> %s %s\n\n", row.Provider, row.ID, converted.Provider, converted.ID) + printResumeRecipe(stdout, session.RecipeFor(converted, resumeOptions)) + return 0 +} + +func runInfo(args []string, stdout, stderr io.Writer) int { + id := "" + options := session.ResumeOptions{} + for _, arg := range args { + switch { + case arg == "--yolo": + options.Dangerous = true + case strings.HasPrefix(arg, "-"): + return usageError(stderr, fmt.Sprintf("unknown info argument %q", arg)) + case id == "": + id = arg + default: + return usageError(stderr, fmt.Sprintf("unexpected info argument %q", arg)) + } + } + if id == "" { + return usageError(stderr, "info needs a session id or 'latest'") + } + row, err := resolveSession(session.Discover(), id) + if err != nil { + _, _ = fmt.Fprintf(stderr, "showagent: %v\n", err) + return 1 + } + printResumeRecipe(stdout, session.RecipeFor(row, options)) + return 0 +} + // resolveSession maps a user-supplied id (or the literal "latest") to a // discovered session row. Rows are expected newest-first, as returned by // session.Discover. @@ -219,6 +334,67 @@ func runSetup(stdout, stderr io.Writer) int { return 0 } +func parseHandoffScope(value string) (session.HandoffOptions, error) { + value = strings.TrimSpace(value) + if value == "" || value == "all" { + return session.HandoffOptions{}, nil + } + switch { + case strings.HasPrefix(value, "last:"): + value = strings.TrimPrefix(value, "last:") + case strings.HasPrefix(value, "last-"): + value = strings.TrimPrefix(value, "last-") + default: + return session.HandoffOptions{}, fmt.Errorf("scope must be 'all' or 'last:'") + } + turns, err := strconv.Atoi(value) + if err != nil || turns <= 0 { + return session.HandoffOptions{}, fmt.Errorf("scope must be 'all' or 'last:'") + } + return session.HandoffOptions{MaxTurns: turns}, nil +} + +func printConversionPreview(w io.Writer, preview session.ConversionPreview) { + _, _ = fmt.Fprintf(w, "conversion preview\n") + _, _ = fmt.Fprintf(w, " source: %s %s\n", preview.SourceProvider, preview.SourceID) + _, _ = fmt.Fprintf(w, " read from: %s\n", preview.SourceLocation) + _, _ = fmt.Fprintf(w, " target: %s\n", preview.TargetProvider) + _, _ = fmt.Fprintf(w, " workspace: %s\n", preview.Workspace) + _, _ = fmt.Fprintf(w, " scope: %s (%d transferable turns)\n", preview.Scope, preview.TransferTurns) + if preview.LastUser != "" { + _, _ = fmt.Fprintf(w, " last user: %s\n", preview.LastUser) + } + _, _ = fmt.Fprintln(w, " dropped:") + for _, dropped := range preview.Dropped { + _, _ = fmt.Fprintf(w, " - %s\n", dropped) + } + if len(preview.Warnings) > 0 { + _, _ = fmt.Fprintln(w, " warnings:") + for _, warning := range preview.Warnings { + _, _ = fmt.Fprintf(w, " - %s\n", warning) + } + } +} + +func printResumeRecipe(w io.Writer, recipe session.ResumeRecipe) { + _, _ = fmt.Fprintf(w, "resume recipe\n") + _, _ = fmt.Fprintf(w, " provider: %s\n", recipe.Provider) + _, _ = fmt.Fprintf(w, " session: %s\n", recipe.ID) + _, _ = fmt.Fprintf(w, " command: %s\n", recipe.CommandString) + _, _ = fmt.Fprintf(w, " cwd: %s\n", emptyFallback(recipe.CWD)) + _, _ = fmt.Fprintf(w, " storage: %s\n", recipe.StorageLocation) + if recipe.Note != "" { + _, _ = fmt.Fprintf(w, " note: %s\n", recipe.Note) + } +} + +func emptyFallback(value string) string { + if strings.TrimSpace(value) == "" { + return "(none)" + } + return value +} + func usageError(stderr io.Writer, message string) int { _, _ = fmt.Fprintf(stderr, "showagent: %s\n%s\nrun 'showagent --help' for details\n", message, usageLine) return 2 @@ -232,6 +408,11 @@ Usage: showagent list [--json] print sessions (plain table, or JSON with --json) showagent resume [--yolo] resume a session directly, without the picker + showagent convert --to [--scope all|last:50] [--dry-run] + preview or write a native session for another agent + showagent info [--yolo] + print the exact resume command and storage location + showagent update install the latest GitHub release showagent setup install the compound-engineering plugin for supported agents Flags: @@ -239,10 +420,13 @@ Flags: -v, --version print version --json (list) emit a JSON array of sessions --yolo (resume) skip the agent's permission prompts + --to (convert) target provider: %s + --scope (convert) all, or last:N / last-N + --dry-run (convert) preview without writing anything Picker keys: enter resume · y yolo · space collapse group · / search · t scope - x hand off to another agent · n branch a copy · C compound · d/del delete + x preview/confirm hand-off · n branch a copy · C compound · d/del delete p cycle preview (first/latest/both) · 1..9 toggle providers · r rescan ? full help · esc clear search/overlay · q quit @@ -255,7 +439,7 @@ Session locations: gemini ~/.gemini/tmp (override with GEMINI_CLI_HOME) When stdout is not a terminal, 'showagent' prints the plain table (same as 'showagent list'). -`) +`, strings.Join(session.ProviderNames(), ", ")) } // versionString prefers the release-stamped version and falls back to Go diff --git a/cmd/showagent/main_test.go b/cmd/showagent/main_test.go index 9a4efb2..7170909 100644 --- a/cmd/showagent/main_test.go +++ b/cmd/showagent/main_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -65,7 +67,7 @@ func TestHelpFlag(t *testing.T) { if code != 0 { t.Fatalf("%s exit = %d, want 0", flag, code) } - for _, want := range []string{"Usage:", "list", "resume", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "--yolo", "--json"} { + for _, want := range []string{"Usage:", "list", "resume", "convert", "info", "update", "setup", "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", "--yolo", "--json", "--dry-run"} { if !strings.Contains(stdout, want) { t.Fatalf("%s output missing %q:\n%s", flag, want, stdout) } @@ -252,6 +254,124 @@ func TestResumeWithoutIDExitsTwo(t *testing.T) { } } +func TestConvertDryRunPrintsPreviewWithoutWriting(t *testing.T) { + setFixtureHomes(t) + code, stdout, stderr := runCLI(t, "convert", codexID, "--to", "claude", "--scope", "last:1", "--dry-run") + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%s", code, stderr) + } + for _, want := range []string{ + "conversion preview", + "source: codex " + codexID, + "target: claude", + "scope: last 1 (1 transferable turns)", + "last user: last codex message", + "tool calls and tool results", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("preview missing %q:\n%s", want, stdout) + } + } + if matches, _ := filepath.Glob(filepath.Join(os.Getenv("CLAUDE_HOME"), "projects", "*", "*.jsonl")); len(matches) != 1 { + t.Fatalf("dry-run should not create a converted claude file, matches=%v", matches) + } +} + +func TestInfoPrintsResumeRecipe(t *testing.T) { + setFixtureHomes(t) + code, stdout, stderr := runCLI(t, "info", claudeID) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%s", code, stderr) + } + for _, want := range []string{ + "resume recipe", + "provider: claude", + "session: " + claudeID, + "command: claude --resume " + claudeID, + "storage:", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("recipe missing %q:\n%s", want, stdout) + } + } +} + +func TestParseHandoffScope(t *testing.T) { + tests := []struct { + value string + want int + ok bool + }{ + {"all", 0, true}, + {"", 0, true}, + {"last:3", 3, true}, + {"last-4", 4, true}, + {"3", 0, false}, + {"last:3x", 0, false}, + {"last:0", 0, false}, + {"last:-1", 0, false}, + } + + for _, tt := range tests { + got, err := parseHandoffScope(tt.value) + if tt.ok { + if err != nil { + t.Fatalf("parseHandoffScope(%q) err = %v, want nil", tt.value, err) + } + if got.MaxTurns != tt.want { + t.Fatalf("parseHandoffScope(%q).MaxTurns = %d, want %d", tt.value, got.MaxTurns, tt.want) + } + continue + } + if err == nil { + t.Fatalf("parseHandoffScope(%q) err = nil, want error", tt.value) + } + } +} + +func TestUpdateCheckReportsNewerRelease(t *testing.T) { + savedURL := latestReleaseURL + savedVersion := version + defer func() { + latestReleaseURL = savedURL + version = savedVersion + }() + + version = "v0.7.0" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"tag_name":"v0.8.0"}`)) + })) + defer server.Close() + latestReleaseURL = server.URL + + code, stdout, stderr := runCLI(t, "update", "--check") + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "showagent v0.8.0 is available (current v0.7.0)") { + t.Fatalf("unexpected update check output:\n%s", stdout) + } +} + +func TestIsNewerVersion(t *testing.T) { + tests := []struct { + candidate string + current string + want bool + }{ + {"v0.8.0", "v0.7.0", true}, + {"v0.7.1", "v0.7.1", false}, + {"v1.0.0", "v0.9.9", true}, + {"not-a-version", "v0.7.0", false}, + {"v0.8.0", "dev", true}, + } + for _, tt := range tests { + if got := isNewerVersion(tt.candidate, tt.current); got != tt.want { + t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tt.candidate, tt.current, got, tt.want) + } + } +} + func TestTerminalWidthFallsBackForNonTerminal(t *testing.T) { var buf bytes.Buffer if got := terminalWidth(&buf); got != 120 { @@ -285,7 +405,7 @@ func TestListEmptyExplainsScannedDirs(t *testing.T) { filepath.Join(root, "empty-codex", "sessions"), filepath.Join(root, "empty-claude", "projects"), "CODEX_HOME", "CLAUDE_HOME", "JCODE_HOME", - "start a conversation with codex or claude", + "start a conversation with a supported agent", } { if !strings.Contains(stderr, want) { t.Fatalf("stderr missing %q:\n%s", want, stderr) diff --git a/cmd/showagent/update.go b/cmd/showagent/update.go new file mode 100644 index 0000000..6d2666b --- /dev/null +++ b/cmd/showagent/update.go @@ -0,0 +1,447 @@ +package main + +import ( + "archive/tar" + "archive/zip" + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +const ( + defaultLatestReleaseURL = "https://api.github.com/repos/aytzey/showagent/releases/latest" + defaultReleaseBaseURL = "https://github.com/aytzey/showagent/releases/download" +) + +var ( + latestReleaseURL = defaultLatestReleaseURL + releaseBaseURL = defaultReleaseBaseURL + httpClient = http.DefaultClient + currentRuntimeGOOS = runtime.GOOS + currentRuntimeArch = runtime.GOARCH +) + +type githubRelease struct { + TagName string `json:"tag_name"` +} + +func runUpdate(args []string, stdout, stderr io.Writer) int { + checkOnly := false + for _, arg := range args { + switch arg { + case "--check": + checkOnly = true + default: + return usageError(stderr, fmt.Sprintf("unknown update argument %q", arg)) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + latest, err := fetchLatestRelease(ctx) + if err != nil { + _, _ = fmt.Fprintf(stderr, "showagent update: %v\n", err) + return 1 + } + current := versionString() + if !isNewerVersion(latest, current) { + _, _ = fmt.Fprintf(stdout, "showagent is up to date (%s)\n", current) + return 0 + } + + _, _ = fmt.Fprintf(stdout, "showagent %s is available (current %s)\n", latest, current) + if checkOnly { + return 0 + } + if err := installRelease(latest, stdout, stderr); err != nil { + _, _ = fmt.Fprintf(stderr, "showagent update: %v\n", err) + return 1 + } + return 0 +} + +func maybePromptForUpdate(stdin *os.File, stderr io.Writer) (code int, handled bool) { + if os.Getenv("SHOWAGENT_NO_UPDATE_CHECK") != "" { + return 0, false + } + current := versionString() + if !strings.HasPrefix(current, "v") { + return 0, false + } + + ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) + defer cancel() + latest, err := fetchLatestRelease(ctx) + if err != nil || !isNewerVersion(latest, current) { + return 0, false + } + + _, _ = fmt.Fprintf(stderr, "showagent %s is available (current %s). Install now? [y/N] ", latest, current) + answer, err := bufio.NewReader(stdin).ReadString('\n') + if err != nil { + _, _ = fmt.Fprintln(stderr) + return 0, false + } + answer = strings.ToLower(strings.TrimSpace(answer)) + if answer != "y" && answer != "yes" { + return 0, false + } + + if err := installRelease(latest, stderr, stderr); err != nil { + _, _ = fmt.Fprintf(stderr, "showagent update: %v\n", err) + return 1, true + } + _, _ = fmt.Fprintln(stderr, "showagent updated. Run showagent again to use the new version.") + return 0, true +} + +func fetchLatestRelease(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, latestReleaseURL, nil) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "showagent/"+versionString()) + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("GitHub releases returned %s", resp.Status) + } + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", err + } + if release.TagName == "" { + return "", errors.New("latest release response has no tag_name") + } + return release.TagName, nil +} + +func installRelease(tag string, stdout, stderr io.Writer) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + asset, binaryName, err := releaseAsset(tag) + if err != nil { + return err + } + base := strings.TrimRight(releaseBaseURL, "/") + "/" + tag + _, _ = fmt.Fprintf(stdout, "Downloading %s (%s)...\n", asset, tag) + + archiveData, err := downloadBytes(ctx, base+"/"+asset) + if err != nil { + return fmt.Errorf("download %s: %w", asset, err) + } + sumsData, err := downloadBytes(ctx, base+"/SHA256SUMS") + if err != nil { + return fmt.Errorf("download SHA256SUMS: %w", err) + } + if err := verifySHA256(archiveData, string(sumsData), asset); err != nil { + return err + } + + tmpdir, err := os.MkdirTemp("", "showagent-update-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpdir) }() + + extracted := filepath.Join(tmpdir, binaryName) + if err := extractReleaseBinary(archiveData, asset, binaryName, extracted); err != nil { + return err + } + target, err := installDestination(binaryName) + if err != nil { + return err + } + if err := installBinary(extracted, target); err != nil { + return err + } + _, _ = fmt.Fprintf(stdout, "Installed showagent %s to %s\n", tag, target) + if !pathContains(filepath.Dir(target)) { + _, _ = fmt.Fprintf(stderr, "Note: %s is not on your PATH\n", filepath.Dir(target)) + } + return nil +} + +func downloadBytes(ctx context.Context, rawURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "showagent/"+versionString()) + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("server returned %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +func releaseAsset(tag string) (asset string, binaryName string, err error) { + goos := currentRuntimeGOOS + goarch := currentRuntimeArch + switch goarch { + case "amd64", "arm64": + default: + return "", "", fmt.Errorf("unsupported architecture %s; download a release manually from https://github.com/aytzey/showagent/releases", goarch) + } + + binaryName = "showagent" + extension := ".tar.gz" + switch goos { + case "linux", "darwin": + case "windows": + if goarch != "amd64" { + return "", "", fmt.Errorf("automatic update is not published for windows/%s yet", goarch) + } + binaryName = "showagent.exe" + extension = ".zip" + default: + return "", "", fmt.Errorf("unsupported OS %s; download a release manually from https://github.com/aytzey/showagent/releases", goos) + } + return fmt.Sprintf("showagent_%s_%s_%s%s", tag, goos, goarch, extension), binaryName, nil +} + +func verifySHA256(data []byte, sums string, asset string) error { + expected, err := checksumForAsset(sums, asset) + if err != nil { + return err + } + actualBytes := sha256.Sum256(data) + actual := fmt.Sprintf("%x", actualBytes[:]) + if actual != expected { + return fmt.Errorf("checksum mismatch for %s: expected %s, got %s", asset, expected, actual) + } + return nil +} + +func checksumForAsset(sums string, asset string) (string, error) { + for _, line := range strings.Split(sums, "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + name := strings.TrimPrefix(fields[len(fields)-1], "*") + if name == asset { + if len(fields[0]) != sha256.Size*2 { + return "", fmt.Errorf("invalid SHA256SUMS entry for %s", asset) + } + return strings.ToLower(fields[0]), nil + } + } + return "", fmt.Errorf("no SHA256SUMS entry for %s", asset) +} + +func extractReleaseBinary(archiveData []byte, asset string, binaryName string, destination string) error { + if strings.HasSuffix(asset, ".zip") { + return extractZipBinary(archiveData, binaryName, destination) + } + return extractTarBinary(archiveData, binaryName, destination) +} + +func extractZipBinary(archiveData []byte, binaryName string, destination string) error { + reader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + if err != nil { + return err + } + for _, entry := range reader.File { + if filepath.Base(entry.Name) != binaryName { + continue + } + source, err := entry.Open() + if err != nil { + return err + } + defer func() { _ = source.Close() }() + return writeExtractedBinary(source, destination) + } + return fmt.Errorf("%s not found in release archive", binaryName) +} + +func extractTarBinary(archiveData []byte, binaryName string, destination string) error { + gz, err := gzip.NewReader(bytes.NewReader(archiveData)) + if err != nil { + return err + } + defer func() { _ = gz.Close() }() + + reader := tar.NewReader(gz) + for { + header, err := reader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return err + } + if header.Typeflag != tar.TypeReg || filepath.Base(header.Name) != binaryName { + continue + } + return writeExtractedBinary(reader, destination) + } + return fmt.Errorf("%s not found in release archive", binaryName) +} + +func writeExtractedBinary(source io.Reader, destination string) error { + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return err + } + out, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return err + } + if _, copyErr := io.Copy(out, source); copyErr != nil { + _ = out.Close() + return copyErr + } + return out.Close() +} + +func installDestination(binaryName string) (string, error) { + if installDir := os.Getenv("SHOWAGENT_INSTALL_DIR"); installDir != "" { + return filepath.Join(expandInstallHome(installDir), binaryName), nil + } + if executable, err := os.Executable(); err == nil && strings.EqualFold(filepath.Base(executable), binaryName) && dirWritable(filepath.Dir(executable)) { + return executable, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot find home directory; set SHOWAGENT_INSTALL_DIR") + } + return filepath.Join(home, ".local", "bin", binaryName), nil +} + +func installBinary(source string, destination string) error { + dir := filepath.Dir(destination) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create install dir %s: %w", dir, err) + } + temp, err := os.CreateTemp(dir, ".showagent-*") + if err != nil { + return fmt.Errorf("create temp install file in %s: %w", dir, err) + } + tempName := temp.Name() + defer func() { _ = os.Remove(tempName) }() + + input, err := os.Open(source) + if err != nil { + _ = temp.Close() + return err + } + if _, err := io.Copy(temp, input); err != nil { + _ = input.Close() + _ = temp.Close() + return err + } + if err := input.Close(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Chmod(0o755); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, destination); err != nil { + return fmt.Errorf("install to %s: %w", destination, err) + } + return nil +} + +func dirWritable(dir string) bool { + file, err := os.CreateTemp(dir, ".showagent-write-test-*") + if err != nil { + return false + } + name := file.Name() + _ = file.Close() + _ = os.Remove(name) + return true +} + +func pathContains(dir string) bool { + for _, entry := range filepath.SplitList(os.Getenv("PATH")) { + if entry == dir { + return true + } + } + return false +} + +func expandInstallHome(path string) string { + if path == "~" { + if home, err := os.UserHomeDir(); err == nil { + return home + } + } + if strings.HasPrefix(path, "~/") { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, path[2:]) + } + } + return path +} + +func isNewerVersion(candidate, current string) bool { + candidateParts, ok := parseVersion(candidate) + if !ok { + return false + } + currentParts, ok := parseVersion(current) + if !ok { + return true + } + for i := 0; i < len(candidateParts); i++ { + if candidateParts[i] != currentParts[i] { + return candidateParts[i] > currentParts[i] + } + } + return false +} + +func parseVersion(value string) ([3]int, bool) { + var result [3]int + value = strings.TrimPrefix(strings.TrimSpace(value), "v") + fields := strings.Split(value, ".") + if len(fields) == 0 { + return result, false + } + for i := 0; i < len(result); i++ { + if i >= len(fields) { + break + } + field := fields[i] + if cut := strings.IndexAny(field, "-+"); cut >= 0 { + field = field[:cut] + } + number, err := strconv.Atoi(field) + if err != nil || number < 0 { + return result, false + } + result[i] = number + } + return result, true +} diff --git a/cmd/showagent/update_test.go b/cmd/showagent/update_test.go new file mode 100644 index 0000000..50ac4d5 --- /dev/null +++ b/cmd/showagent/update_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "crypto/sha256" + "fmt" + "strings" + "testing" +) + +func TestReleaseAsset(t *testing.T) { + savedGOOS := currentRuntimeGOOS + savedArch := currentRuntimeArch + defer func() { + currentRuntimeGOOS = savedGOOS + currentRuntimeArch = savedArch + }() + + tests := []struct { + goos string + goarch string + wantAsset string + wantBinary string + wantErr bool + }{ + {"linux", "amd64", "showagent_v0.8.0_linux_amd64.tar.gz", "showagent", false}, + {"darwin", "arm64", "showagent_v0.8.0_darwin_arm64.tar.gz", "showagent", false}, + {"windows", "amd64", "showagent_v0.8.0_windows_amd64.zip", "showagent.exe", false}, + {"windows", "arm64", "", "", true}, + {"plan9", "amd64", "", "", true}, + } + for _, tt := range tests { + currentRuntimeGOOS = tt.goos + currentRuntimeArch = tt.goarch + asset, binaryName, err := releaseAsset("v0.8.0") + if tt.wantErr { + if err == nil { + t.Fatalf("releaseAsset(%s/%s) err = nil, want error", tt.goos, tt.goarch) + } + continue + } + if err != nil { + t.Fatalf("releaseAsset(%s/%s) err = %v", tt.goos, tt.goarch, err) + } + if asset != tt.wantAsset || binaryName != tt.wantBinary { + t.Fatalf("releaseAsset(%s/%s) = %q, %q; want %q, %q", tt.goos, tt.goarch, asset, binaryName, tt.wantAsset, tt.wantBinary) + } + } +} + +func TestVerifySHA256(t *testing.T) { + payload := []byte("release archive") + sum := sha256.Sum256(payload) + sums := fmt.Sprintf("%x *showagent_v0.8.0_linux_amd64.tar.gz\n", sum[:]) + + if err := verifySHA256(payload, sums, "showagent_v0.8.0_linux_amd64.tar.gz"); err != nil { + t.Fatalf("verifySHA256 err = %v, want nil", err) + } + if err := verifySHA256([]byte("tampered"), sums, "showagent_v0.8.0_linux_amd64.tar.gz"); err == nil || !strings.Contains(err.Error(), "checksum mismatch") { + t.Fatalf("tampered verify err = %v, want checksum mismatch", err) + } + if err := verifySHA256(payload, sums, "missing.tar.gz"); err == nil || !strings.Contains(err.Error(), "no SHA256SUMS entry") { + t.Fatalf("missing verify err = %v, want missing entry", err) + } +} diff --git a/demo/README.md b/demo/README.md index 7b02d50..40f9861 100644 --- a/demo/README.md +++ b/demo/README.md @@ -10,7 +10,7 @@ binaries, so no real sessions are read and no real agent is launched. |---|---| | `demo/fixtures/gen.sh` | Fabricates `demo/.home`: 5 Codex + 5 Claude Code + 2 Gemini CLI sessions across 3 fake workspaces (`code/api-server`, `code/webapp`, `dotfiles`), timestamped relative to now. | | `demo/bin/codex`, `demo/bin/claude` | Stub CLIs that print a mock "session resumed" screen, so the resume-after-convert beat lands without real agents. | -| `demo/demo.tape` | The [vhs](https://github.com/charmbracelet/vhs) script: launch, browse, preview cycle, search, convert (`x`), resume, end card. | +| `demo/demo.tape` | The [vhs](https://github.com/charmbracelet/vhs) script: launch, browse, preview cycle, search, preview+confirm convert (`x`, `x`), resume, end card. | | `demo/.home`, `demo/.build` | Generated at record time; gitignored. | ## Regenerate the GIF @@ -59,5 +59,5 @@ ffmpeg -y -ss 14 -i docs/demo.gif -frames:v 1 \ - **Size budget**: target < 3 MB. If the GIF comes out larger, drop the tape to `Set Width 1000` / `Set FontSize 18`. - **Keybindings**: the tape encodes `/` (search), `p` (preview cycle), - `x` (convert / hand off), `enter` (resume). If bindings change in + `x` (preview / confirm hand off), `enter` (resume). If bindings change in `internal/tui/keys.go`, update the tape before re-recording. diff --git a/demo/demo.tape b/demo/demo.tape index 5859582..31294de 100644 --- a/demo/demo.tape +++ b/demo/demo.tape @@ -11,8 +11,8 @@ # are never read or shown. # # Keys used (verify against internal/tui/keys.go before re-recording): -# p cycles the preview column, / searches, x converts to the target -# agent, enter resumes. Provider filters live on the digit keys 1..9. +# p cycles the preview column, / searches, x previews and x again confirms +# conversion to the target agent, enter resumes. Provider filters live on 1..9. Output docs/demo.gif @@ -67,7 +67,9 @@ Sleep 1.5s Enter Sleep 1.5s -# --- 5. money shot: convert the Codex session to Claude Code ------------------ +# --- 5. money shot: preview, then convert the Codex session to Claude Code ---- +Type "x" +Sleep 2s Type "x" Sleep 3.5s diff --git a/docs/demo.gif b/docs/demo.gif index 16701a1..c428970 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/social-preview.png b/docs/social-preview.png index 316c8dd..633a24e 100644 Binary files a/docs/social-preview.png and b/docs/social-preview.png differ diff --git a/internal/session/codex.go b/internal/session/codex.go index 584208a..1c10122 100644 --- a/internal/session/codex.go +++ b/internal/session/codex.go @@ -2,12 +2,14 @@ package session import ( "bufio" + "context" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" + "time" ) // codexProvider adapts the Codex CLI's session store (rollout-*.jsonl files @@ -46,11 +48,17 @@ func (p codexProvider) CompoundArgs(row Row, options ResumeOptions, prompt strin // Delete shells out to the codex CLI so codex can keep its own bookkeeping // (history metadata) consistent instead of us unlinking the rollout file. func (codexProvider) Delete(row Row) error { - command := exec.Command("codex", "delete", "--force", row.ID) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + command := exec.CommandContext(ctx, "codex", "delete", "--force", row.ID) if info, err := os.Stat(row.CWD); err == nil && info.IsDir() { command.Dir = row.CWD } if output, err := command.CombinedOutput(); err != nil { + if ctx.Err() == context.DeadlineExceeded { + return fmt.Errorf("codex delete timed out after 30s: %w", ctx.Err()) + } return fmt.Errorf("codex delete failed: %w: %s", err, strings.TrimSpace(string(output))) } return nil diff --git a/internal/session/opencode.go b/internal/session/opencode.go index f9e7b4c..a068164 100644 --- a/internal/session/opencode.go +++ b/internal/session/opencode.go @@ -450,11 +450,21 @@ func runOpenCode(dir string, args ...string) ([]byte, error) { // decodeOpenCodeJSON unmarshals the first JSON payload in CLI output, // tolerating banner or notice lines the CLI may print before it. func decodeOpenCodeJSON(output []byte, value any) error { - index := bytes.IndexAny(output, "[{") - if index < 0 { - return errors.New("no JSON payload in opencode output") + offset := 0 + for _, line := range bytes.SplitAfter(output, []byte{'\n'}) { + trimmed := bytes.TrimSpace(line) + if len(trimmed) == 0 { + offset += len(line) + continue + } + leading := len(line) - len(bytes.TrimLeft(line, " \t\r\n")) + lineOffset := offset + leading + offset += len(line) + if trimmed[0] == '[' || trimmed[0] == '{' { + return json.Unmarshal(output[lineOffset:], value) + } } - return json.Unmarshal(output[index:], value) + return errors.New("no JSON payload in opencode output") } // existingDir returns cwd when it is a real directory, and "" otherwise, for diff --git a/internal/session/opencode_test.go b/internal/session/opencode_test.go index 6630aa6..ba63cdd 100644 --- a/internal/session/opencode_test.go +++ b/internal/session/opencode_test.go @@ -392,6 +392,17 @@ func TestConvertToOpenCodeNeedsWorkspace(t *testing.T) { } } +func TestDecodeOpenCodeJSONIgnoresBracketedBannerText(t *testing.T) { + var entries []opencodeSessionEntry + output := []byte("notice: [beta] opencode db output follows\n[{\"id\":\"ses_1\",\"created\":1,\"updated\":2}]\n") + if err := decodeOpenCodeJSON(output, &entries); err != nil { + t.Fatalf("decodeOpenCodeJSON failed: %v", err) + } + if len(entries) != 1 || entries[0].ID != "ses_1" { + t.Fatalf("entries = %#v, want ses_1", entries) + } +} + func TestOpenCodeIDShape(t *testing.T) { at := time.UnixMilli(1751504400000) ascending, err := opencodeID("msg", false, at) diff --git a/internal/session/provider.go b/internal/session/provider.go index 9f11152..61f1a2e 100644 --- a/internal/session/provider.go +++ b/internal/session/provider.go @@ -1,5 +1,10 @@ package session +import ( + "fmt" + "strings" +) + // ProviderImpl bundles everything showagent needs to support one agent CLI. // Adding an agent means implementing this interface in one file and appending // it to the registry below; no other dispatch site exists. @@ -60,6 +65,26 @@ func ProviderOrder() []Provider { return order } +// ParseProvider resolves a CLI/user-supplied provider name against the +// registry. The returned value is the stable lowercase provider id. +func ParseProvider(value string) (Provider, error) { + for _, impl := range registry { + if string(impl.Name()) == value { + return impl.Name(), nil + } + } + return "", fmt.Errorf("unsupported provider %q (supported: %s)", value, strings.Join(ProviderNames(), ", ")) +} + +// ProviderNames returns the stable lowercase provider ids in registry order. +func ProviderNames() []string { + names := make([]string, 0, len(registry)) + for _, impl := range registry { + names = append(names, string(impl.Name())) + } + return names +} + // DisplayName is the human-facing name for provider ("Codex"). Unknown // providers fall back to their raw name. func DisplayName(provider Provider) string { diff --git a/internal/session/recipe.go b/internal/session/recipe.go new file mode 100644 index 0000000..455db4d --- /dev/null +++ b/internal/session/recipe.go @@ -0,0 +1,153 @@ +package session + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ConversionPreview is the trust-before-write summary shown by `showagent +// convert --dry-run` and by the picker before the user confirms a hand-off. +type ConversionPreview struct { + SourceProvider Provider + SourceID string + SourceLocation string + TargetProvider Provider + Workspace string + Scope string + TransferTurns int + LastUser string + Dropped []string + Warnings []string +} + +// ResumeRecipe explains exactly how showagent will resume a row and where that +// session lives. It is intentionally plain data so CLI, TUI, and tests can +// render it differently without duplicating provider knowledge. +type ResumeRecipe struct { + Provider Provider + ID string + Command []string + CommandString string + CWD string + SourceLocation string + StorageLocation string + Note string +} + +// PreviewConversion extracts the transferable transcript and summarizes the +// hand-off without writing anything into the target agent's session store. +func PreviewConversion(row Row, target Provider, options HandoffOptions) (ConversionPreview, error) { + if _, ok := providerFor(target); !ok { + return ConversionPreview{}, fmt.Errorf("unsupported target provider %q", target) + } + + turns, err := Transcript(row) + if err != nil { + return ConversionPreview{}, err + } + turns = options.apply(turns) + if len(turns) == 0 { + return ConversionPreview{}, fmt.Errorf("source session has no transferable user or assistant turns") + } + + _, lastUser := userPreviewFromTurns(turns) + preview := ConversionPreview{ + SourceProvider: row.Provider, + SourceID: row.ID, + SourceLocation: SourceLocation(row), + TargetProvider: target, + Workspace: row.resumeCWD(), + Scope: options.Label(), + TransferTurns: len(turns), + LastUser: lastUser, + Dropped: []string{ + "tool calls and tool results", + "permission/approval state", + "model, provider, token, and runtime metadata", + "sidechain, subagent, cache, and checkpoint internals", + }, + } + if row.Provider == target { + preview.Warnings = append(preview.Warnings, "target provider matches source; this creates a local branch/copy") + } + if strings.TrimSpace(row.resumeCWD()) == "" || strings.HasPrefix(strings.TrimSpace(row.resumeCWD()), "(") { + preview.Warnings = append(preview.Warnings, "source workspace is unknown; some target providers may refuse conversion") + } + if target == ProviderOpenCode && !ProviderCommandAvailable(ProviderOpenCode) { + preview.Warnings = append(preview.Warnings, "opencode CLI is not on PATH; writing the conversion will fail until it is installed") + } + return preview, nil +} + +// RecipeFor builds the resume recipe for an existing or newly-created session. +func RecipeFor(row Row, options ResumeOptions) ResumeRecipe { + command := row.ResumeCommand(options) + return ResumeRecipe{ + Provider: row.Provider, + ID: row.ID, + Command: command, + CommandString: ShellCommand(command), + CWD: row.resumeCWD(), + SourceLocation: SourceLocation(row), + StorageLocation: StorageLocation(row), + Note: recipeNote(row), + } +} + +func SourceLocation(row Row) string { + if row.File != "" { + return row.File + } + switch row.Provider { + case ProviderOpenCode: + return filepath.Join(defaultOpenCodeDataHome(), "opencode*.db") + " via `opencode export " + row.ID + "`" + default: + return "(unknown source location)" + } +} + +func StorageLocation(row Row) string { + if row.File != "" { + return row.File + } + switch row.Provider { + case ProviderOpenCode: + return filepath.Join(defaultOpenCodeDataHome(), "opencode*.db") + " via `opencode import`" + default: + return "(unknown storage location)" + } +} + +func recipeNote(row Row) string { + if row.Provider == ProviderOpenCode { + return "OpenCode sessions live in its SQLite database; showagent reads and writes through the opencode CLI." + } + return "" +} + +// ShellCommand renders argv in a copy-pasteable shell form. +func ShellCommand(args []string) string { + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, shellQuote(arg)) + } + return strings.Join(quoted, " ") +} + +func shellQuote(arg string) string { + if arg == "" { + return "''" + } + if strings.IndexFunc(arg, func(r rune) bool { return !isShellSafeRune(r) }) == -1 { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", "'\"'\"'") + "'" +} + +func isShellSafeRune(r rune) bool { + return r == '-' || r == '_' || r == '.' || r == '/' || r == ':' || r == '=' || + (r >= '0' && r <= '9') || + (r >= 'A' && r <= 'Z') || + (r >= 'a' && r <= 'z') +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 17e22fc..795ea35 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -678,3 +678,59 @@ func TestValidateResume(t *testing.T) { t.Fatalf("compound agent err = %v, want 'codex not found in PATH'", err) } } + +func TestPreviewConversionSummarizesTransferWithoutWriting(t *testing.T) { + root := t.TempDir() + setEmptyHomes(t, root) + sourcePath := filepath.Join(root, "source.jsonl") + writeFile(t, sourcePath, ` +{"timestamp":"2026-06-01T09:00:00Z","type":"session_meta","payload":{"id":"aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb","cwd":"/work/codex"}} +{"timestamp":"2026-06-01T09:01:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first ask"}]}} +{"timestamp":"2026-06-01T09:02:00Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}} +{"timestamp":"2026-06-01T09:03:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"last ask"}]}} +`) + + preview, err := PreviewConversion(Row{ + Provider: ProviderCodex, + ID: "aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb", + CWD: "/work/codex", + File: sourcePath, + }, ProviderClaude, HandoffOptions{MaxTurns: 2}) + if err != nil { + t.Fatal(err) + } + if preview.SourceProvider != ProviderCodex || preview.TargetProvider != ProviderClaude { + t.Fatalf("unexpected providers: %#v", preview) + } + if preview.Scope != "last 2" || preview.TransferTurns != 2 || preview.LastUser != "last ask" { + t.Fatalf("unexpected transfer summary: %#v", preview) + } + if preview.SourceLocation != sourcePath { + t.Fatalf("source location = %q, want %q", preview.SourceLocation, sourcePath) + } + if !strings.Contains(strings.Join(preview.Dropped, " "), "tool") { + t.Fatalf("preview should explain dropped agent internals: %#v", preview.Dropped) + } + if _, err := os.Stat(filepath.Join(root, "claude")); !os.IsNotExist(err) { + t.Fatalf("dry preview should not create target files, stat err=%v", err) + } +} + +func TestRecipeForQuotesCommandAndReportsStorage(t *testing.T) { + row := Row{ + Provider: ProviderClaude, + ID: "session with spaces", + CWD: "/tmp/work space", + File: "/tmp/claude session.jsonl", + } + recipe := RecipeFor(row, ResumeOptions{Dangerous: true}) + if recipe.Provider != ProviderClaude || recipe.StorageLocation != row.File { + t.Fatalf("unexpected recipe: %#v", recipe) + } + if !strings.Contains(recipe.CommandString, "--dangerously-skip-permissions") { + t.Fatalf("recipe command missing yolo flag: %q", recipe.CommandString) + } + if !strings.Contains(recipe.CommandString, "'session with spaces'") { + t.Fatalf("recipe command did not quote session id: %q", recipe.CommandString) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 16b3d73..025923e 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -37,7 +37,7 @@ func defaultKeys() keyMap { Collapse: key.NewBinding(key.WithKeys("space", " "), key.WithHelp("space", "collapse")), Compound: key.NewBinding(key.WithKeys("C"), key.WithHelp("C", "compound")), Target: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "target")), - Convert: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hand off")), + Convert: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "preview")), Branch: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "branch")), Delete: key.NewBinding(key.WithKeys("d", "delete", "backspace"), key.WithHelp("d/del", "delete")), Preview: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "preview")), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 9d51c9b..e5c1b75 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -63,6 +63,21 @@ type sessionMutationMsg struct { err error } +type conversionPreviewMsg struct { + row session.Row + target session.Provider + options session.HandoffOptions + preview session.ConversionPreview + err error +} + +type pendingConversion struct { + rowKey string + target session.Provider + options session.HandoffOptions + preview session.ConversionPreview +} + type sessionsLoadedMsg struct { rows []session.Row } @@ -199,6 +214,7 @@ type model struct { handoffTarget session.Provider selected *Selection deleteArmed string + pendingConvert *pendingConversion busy string loading bool rescanning bool @@ -298,6 +314,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { wasRescan := m.rescanning m.loading = false m.rescanning = false + m.pendingConvert = nil m.allRows = msg.rows if wasRescan { // A rescan keeps the user's provider filter and search; only the @@ -321,6 +338,8 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case sessionMutationMsg: return m.applyMutation(msg) + case conversionPreviewMsg: + return m.applyConversionPreview(msg) case tea.KeyPressMsg: return m.handleKey(msg) } @@ -328,6 +347,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) m.reconcileDeleteArm() + m.reconcilePendingConversion() return m, cmd } @@ -342,13 +362,13 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // The compound chooser is a modal: pick the agent by digit, or cancel. // Digits follow the registry order of compound-capable providers. if m.compoundChoosing { - key := msg.String() - if agents := session.CompoundAgents(); len(key) == 1 && key[0] >= '1' && key[0] <= '9' { - if index := int(key[0] - '1'); index < len(agents) { + keyStr := msg.String() + if agents := session.CompoundAgents(); len(keyStr) == 1 && keyStr[0] >= '1' && keyStr[0] <= '9' { + if index := int(keyStr[0] - '1'); index < len(agents) { return m.startCompound(agents[index]) } } - switch key { + switch keyStr { case "esc", "q", "ctrl+c": m.compoundChoosing = false m.compoundRow = nil @@ -363,6 +383,7 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) m.reconcileDeleteArm() + m.reconcilePendingConversion() return m, cmd } @@ -385,6 +406,10 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.deleteArmed = "" return m, m.list.NewStatusMessage("delete cancelled") } + if m.pendingConvert != nil { + m.pendingConvert = nil + return m, m.list.NewStatusMessage("conversion preview cleared") + } return m, nil } @@ -425,10 +450,12 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.compoundNotice = "" return m, nil case key.Matches(msg, m.keys.Target): + m.pendingConvert = nil return m.cycleHandoffTarget() case key.Matches(msg, m.keys.Convert): return m.startConvert() case key.Matches(msg, m.keys.Branch): + m.pendingConvert = nil return m.startMutation(m.branchSelected(), "branch", "creating session branch…") case key.Matches(msg, m.keys.Delete): return m, m.deleteSelected() @@ -436,12 +463,14 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.dangerous = !m.dangerous return m, m.list.NewStatusMessage("resume mode: " + resumeModeLabel(m.dangerous)) case key.Matches(msg, m.keys.Scope): + m.pendingConvert = nil m.handoff = nextHandoffScope(m.handoff) return m, m.list.NewStatusMessage("hand-off scope: " + m.handoff.Label()) case key.Matches(msg, m.keys.Preview): m.setMode(nextPreviewMode(m.mode)) return m, m.list.NewStatusMessage("preview: " + modeLong(m.mode)) case key.Matches(msg, m.keys.Rescan): + m.pendingConvert = nil m.rescanning = true m.rescanRow = nil if selected, ok := m.list.SelectedItem().(item); ok { @@ -454,6 +483,7 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { } if msg.String() == "/" { + m.pendingConvert = nil rebuild := m.rebuildListForSearch() var cmd tea.Cmd m.list, cmd = m.list.Update(msg) @@ -463,12 +493,14 @@ func (m model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { var cmd tea.Cmd m.list, cmd = m.list.Update(msg) m.reconcileDeleteArm() + m.reconcilePendingConversion() return m, cmd } func (m model) applyMutation(msg sessionMutationMsg) (tea.Model, tea.Cmd) { m.busy = "" m.deleteArmed = "" + m.pendingConvert = nil if msg.err != nil { return m, m.list.NewStatusMessage("action failed: " + msg.err.Error()) } @@ -481,13 +513,29 @@ func (m model) applyMutation(msg sessionMutationMsg) (tea.Model, tea.Cmd) { cmd := m.list.SetItems(m.currentItems()) selectRowItem(&m.list, msg.row) - status := "converted to " + string(msg.row.Provider) + " · press enter to resume" + recipe := session.RecipeFor(msg.row, session.ResumeOptions{Dangerous: m.dangerous}) + status := "converted to " + string(msg.row.Provider) + " · " + recipe.CommandString if msg.kind == mutationBranch { - status = "branched " + string(msg.row.Provider) + " session · press enter to resume" + status = "branched " + string(msg.row.Provider) + " · " + recipe.CommandString } return m, tea.Batch(cmd, m.list.NewStatusMessage(status)) } +func (m model) applyConversionPreview(msg conversionPreviewMsg) (tea.Model, tea.Cmd) { + m.busy = "" + if msg.err != nil { + m.pendingConvert = nil + return m, m.list.NewStatusMessage("preview failed: " + msg.err.Error()) + } + m.pendingConvert = &pendingConversion{ + rowKey: rowKey(msg.row), + target: msg.target, + options: msg.options, + preview: msg.preview, + } + return m, m.list.NewStatusMessage("preview ready · press x again to write the " + string(msg.target) + " session") +} + func (m model) startMutation(cmd tea.Cmd, busy, status string) (tea.Model, tea.Cmd) { if cmd == nil { return m, m.list.NewStatusMessage("no session selected") @@ -505,7 +553,13 @@ func (m model) startConvert() (tea.Model, tea.Cmd) { if target == "" { return m, m.list.NewStatusMessage("no hand-off target available") } - return m.startMutation(m.convertSelected(), "conversion", "converting to "+string(target)+"…") + if m.pendingConvert != nil && + m.pendingConvert.rowKey == rowKey(selected.row) && + m.pendingConvert.target == target && + m.pendingConvert.options == m.handoff { + return m.startMutation(m.convertRow(selected.row, target, m.handoff), "conversion", "converting to "+string(target)+"…") + } + return m.startMutation(m.previewConversion(selected.row, target, m.handoff), "preview", "building conversion preview…") } func (m *model) setMode(mode previewMode) { @@ -532,6 +586,16 @@ func (m *model) reconcileDeleteArm() { } } +func (m *model) reconcilePendingConversion() { + if m.pendingConvert == nil { + return + } + selected, ok := m.list.SelectedItem().(item) + if !ok || rowKey(selected.row) != m.pendingConvert.rowKey { + m.pendingConvert = nil + } +} + func (m *model) resizeList() { if m.width <= 0 || m.height <= 0 { return @@ -582,6 +646,7 @@ func (m model) toggleGroup(path string) (tea.Model, tea.Cmd) { } func (m *model) toggleProvider(provider session.Provider) tea.Cmd { + m.pendingConvert = nil if !providerExists(m.allRows, provider) { return m.list.NewStatusMessage(fmt.Sprintf("no %s sessions found", provider)) } @@ -636,17 +701,14 @@ func (m model) selectResume() (tea.Model, tea.Cmd) { return m, tea.Quit } -func (m *model) convertSelected() tea.Cmd { - selected, ok := m.list.SelectedItem().(item) - if !ok { - return nil - } - row := selected.row - target := m.handoffTargetFor(row) - if target == "" { - return nil +func (m *model) previewConversion(row session.Row, target session.Provider, options session.HandoffOptions) tea.Cmd { + return func() tea.Msg { + preview, err := session.PreviewConversion(row, target, options) + return conversionPreviewMsg{row: row, target: target, options: options, preview: preview, err: err} } - options := m.handoff +} + +func (m *model) convertRow(row session.Row, target session.Provider, options session.HandoffOptions) tea.Cmd { return func() tea.Msg { converted, err := session.Convert(row, target, options) return sessionMutationMsg{kind: mutationConvert, row: converted, err: err} @@ -891,10 +953,33 @@ func (m model) detailView() string { if m.deleteArmed == rowKey(row) { lines = append(lines, th.deleteBanner.Render("⚠ press delete again to permanently remove this session")) } + if pending := m.selectedConversionPreview(row); pending != nil { + lines = append(lines, + th.label.Render(padLabel("handoff"))+fmt.Sprintf("%s → %s", pending.preview.SourceProvider, pending.preview.TargetProvider), + th.label.Render(padLabel("scope"))+fmt.Sprintf("%s · %d turns", pending.preview.Scope, pending.preview.TransferTurns), + th.label.Render(padLabel("last user"))+truncateCells(emptyFallback(pending.preview.LastUser), valueW), + th.label.Render(padLabel("drops"))+truncateCells(strings.Join(pending.preview.Dropped, ", "), valueW), + th.hint.Render("press x again to write · esc cancel · o target · t scope"), + ) + if len(pending.preview.Warnings) > 0 { + lines = append(lines, th.deleteBanner.Render(truncateCells(strings.Join(pending.preview.Warnings, "; "), innerW))) + } + if len(lines) > count { + lines = lines[:count] + } + for i := range lines { + lines[i] = truncateCells(lines[i], innerW) + } + return th.detail.Width(width).Render(strings.Join(lines, "\n")) + } + + recipe := session.RecipeFor(row, session.ResumeOptions{Dangerous: m.dangerous}) lines = append(lines, th.label.Render(padLabel("provider"))+m.providerWord(row.Provider), th.label.Render(padLabel("session"))+row.ID, th.label.Render(padLabel("workspace"))+truncateMiddle(collapseHome(row.CWD), valueW), + th.label.Render(padLabel("storage"))+truncateMiddle(collapseHome(recipe.StorageLocation), valueW), + th.label.Render(padLabel("command"))+truncateCells(recipe.CommandString, valueW), th.label.Render(padLabel("updated"))+localTime(row.LastAt), th.label.Render(padLabel("first"))+truncateCells(emptyFallback(row.FirstUser), valueW), th.label.Render(padLabel("latest"))+truncateCells(emptyFallback(bestLast(row)), valueW), @@ -910,6 +995,13 @@ func (m model) detailView() string { return th.detail.Width(width).Render(strings.Join(lines, "\n")) } +func (m model) selectedConversionPreview(row session.Row) *pendingConversion { + if m.pendingConvert == nil || m.pendingConvert.rowKey != rowKey(row) { + return nil + } + return m.pendingConvert +} + func (m model) providerWord(p session.Provider) string { return providerBadge(m.render.theme, string(p), len(string(p))+2) } @@ -947,7 +1039,7 @@ func (m model) detailLineCount() int { case m.height < 28: return 6 default: - return 8 + return 10 } } @@ -987,7 +1079,7 @@ func (m model) emptyView() string { } lines = append(lines, "", - th.hint.Render("Start a conversation with codex or claude, then press r to rescan."), + th.hint.Render("Start a conversation with a supported agent, then press r to rescan."), th.hint.Render("Press q to quit."), ) body := strings.Join(lines, "\n") diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index ae35c77..bbc2ef9 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -337,16 +337,16 @@ func TestBusyMutationBlocksSecondAction(t *testing.T) { updated, cmd := newModel([]session.Row{row}).Update(tea.KeyPressMsg(tea.Key{Code: 'x'})) if cmd == nil { - t.Fatal("expected convert command") + t.Fatal("expected preview command") } busy := asModel(t, updated) - if busy.busy != "conversion" { - t.Fatalf("busy = %q, want conversion", busy.busy) + if busy.busy != "preview" { + t.Fatalf("busy = %q, want preview", busy.busy) } stillBusy, _ := busy.Update(tea.KeyPressMsg(tea.Key{Code: 'n'})) - if got := asModel(t, stillBusy); got.busy != "conversion" { - t.Fatalf("busy after second action = %q, want conversion", got.busy) + if got := asModel(t, stillBusy); got.busy != "preview" { + t.Fatalf("busy after second action = %q, want preview", got.busy) } notResumed, _ := asModel(t, stillBusy).Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEnter})) @@ -354,7 +354,34 @@ func TestBusyMutationBlocksSecondAction(t *testing.T) { t.Fatal("enter selected a session while mutation was busy") } - done, _ := asModel(t, notResumed).Update(sessionMutationMsg{kind: mutationConvert, row: newRow}) + previewed, _ := asModel(t, notResumed).Update(conversionPreviewMsg{ + row: row, + target: session.ProviderCodex, + options: session.HandoffOptions{}, + preview: session.ConversionPreview{ + SourceProvider: session.ProviderClaude, + SourceID: row.ID, + TargetProvider: session.ProviderCodex, + Scope: "all", + TransferTurns: 1, + Dropped: []string{"tool calls"}, + }, + }) + pm := asModel(t, previewed) + if pm.busy != "" || pm.pendingConvert == nil { + t.Fatalf("preview did not arm conversion confirmation: busy=%q pending=%#v", pm.busy, pm.pendingConvert) + } + + converting, cmd := pm.Update(tea.KeyPressMsg(tea.Key{Code: 'x'})) + if cmd == nil { + t.Fatal("second x should start conversion") + } + cm := asModel(t, converting) + if cm.busy != "conversion" { + t.Fatalf("busy after confirm = %q, want conversion", cm.busy) + } + + done, _ := cm.Update(sessionMutationMsg{kind: mutationConvert, row: newRow}) got := asModel(t, done) if got.busy != "" { t.Fatalf("busy after mutation = %q, want empty", got.busy) @@ -365,6 +392,48 @@ func TestBusyMutationBlocksSecondAction(t *testing.T) { } } +func TestConversionPreviewRendersAndEscCancels(t *testing.T) { + withFakeCommands(t, "claude") + row := session.Row{ + Provider: session.ProviderCodex, + ID: "source", + LastAt: time.Now(), + CWD: "/p/a", + File: "/tmp/source.jsonl", + FirstUser: "hello", + } + m := sizedModel([]session.Row{row}) + previewed, _ := m.Update(conversionPreviewMsg{ + row: row, + target: session.ProviderClaude, + options: session.HandoffOptions{MaxTurns: 20}, + preview: session.ConversionPreview{ + SourceProvider: session.ProviderCodex, + SourceID: "source", + TargetProvider: session.ProviderClaude, + Scope: "last 20", + TransferTurns: 3, + LastUser: "fix the bug", + Dropped: []string{"tool calls", "runtime state"}, + }, + }) + got := asModel(t, previewed) + if got.pendingConvert == nil { + t.Fatal("preview message did not set pending conversion") + } + detail := got.detailView() + for _, want := range []string{"codex", "claude", "last 20", "fix the bug", "press x again"} { + if !strings.Contains(detail, want) { + t.Fatalf("detail missing %q:\n%s", want, detail) + } + } + + cancelled, _ := got.Update(tea.KeyPressMsg(tea.Key{Code: tea.KeyEscape})) + if asModel(t, cancelled).pendingConvert != nil { + t.Fatal("esc did not clear conversion preview") + } +} + // TestDeleteArmClearsOnNavigation guards the two-press delete safety: arming a // delete then moving the cursor must disarm it, so navigating away and back can // never delete without a fresh confirmation.