diff --git a/README.md b/README.md index f2da257..0e6ead9 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,8 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `kernel browsers delete ` - Delete a browser - `-y, --yes` - Skip confirmation prompt - `kernel browsers view ` - Get live view URL for a browser + - `--live` - Show live terminal view of browser (requires iTerm2, Kitty, or Ghostty) + - `--interval ` - Refresh interval for live view (default: 100ms) ### Browser Pools @@ -302,7 +304,8 @@ Create an API key from the [Kernel dashboard](https://dashboard.onkernel.com). - `--y ` - Y coordinate (required) - `--hold-key ` - Modifier keys to hold (repeatable) - `kernel browsers computer screenshot ` - Capture a screenshot - - `--to ` - Output file path for the PNG image (required) + - `--to ` - Output file path for the PNG image + - `--display` - Display screenshot inline in terminal (requires iTerm2, Kitty, or Ghostty) - `--x ` - Top-left X for region capture (optional) - `--y ` - Top-left Y for region capture (optional) - `--width ` - Region width (optional) @@ -459,6 +462,12 @@ kernel browsers delete browser123 --yes # Get live view URL kernel browsers view browser123 +# Show live view in terminal (iTerm2, Kitty, or Ghostty) - press Ctrl+C to exit +kernel browsers view browser123 --live + +# Show live view with custom refresh rate +kernel browsers view browser123 --live --interval 200ms + # Stream browser logs kernel browsers logs stream my-browser --source supervisor --follow --supervisor-process chromium @@ -483,12 +492,18 @@ kernel browsers computer click-mouse my-browser --x 100 --y 200 --num-clicks 2 - # Move the mouse to coordinates (500, 300) kernel browsers computer move-mouse my-browser --x 500 --y 300 -# Take a full screenshot +# Take a full screenshot and save to file kernel browsers computer screenshot my-browser --to screenshot.png # Take a screenshot of a specific region kernel browsers computer screenshot my-browser --to region.png --x 0 --y 0 --width 800 --height 600 +# Display screenshot inline in terminal (iTerm2, Kitty, or Ghostty) +kernel browsers computer screenshot my-browser --display + +# Display and save screenshot +kernel browsers computer screenshot my-browser --display --to screenshot.png + # Type text in the browser kernel browsers computer type my-browser --text "Hello, World!" diff --git a/cmd/browsers.go b/cmd/browsers.go index 36a91d4..2629ca1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -10,11 +10,15 @@ import ( "math/big" "net/http" "os" + "os/signal" "path/filepath" "regexp" "strconv" "strings" + "syscall" + "time" + "github.com/onkernel/cli/pkg/termimg" "github.com/onkernel/cli/pkg/util" "github.com/onkernel/kernel-go-sdk" "github.com/onkernel/kernel-go-sdk/option" @@ -172,6 +176,8 @@ type BrowsersDeleteInput struct { type BrowsersViewInput struct { Identifier string + Live bool + Interval time.Duration } type BrowsersGetInput struct { @@ -456,6 +462,11 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error { } func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { + // If live view requested, run the live view loop + if in.Live { + return b.LiveView(ctx, in) + } + browser, err := b.browsers.Get(ctx, in.Identifier) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -473,6 +484,102 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { return nil } +// LiveView displays a continuously updating view of the browser in the terminal. +func (b BrowsersCmd) LiveView(ctx context.Context, in BrowsersViewInput) error { + if b.computer == nil { + pterm.Error.Println("computer service not available") + return nil + } + + // Check terminal supports inline images + if !termimg.IsSupported() { + pterm.Error.Printf("Terminal does not support inline images (detected: %s). Try using iTerm2, Kitty, or Ghostty.\n", termimg.DetectTerminal()) + return nil + } + + // Verify browser exists and get session ID + browser, err := b.browsers.Get(ctx, in.Identifier) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + // Set default interval if not specified or invalid + interval := in.Interval + if interval <= 0 { + interval = 100 * time.Millisecond + } + + // Set up signal handling for graceful exit + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigChan) + + // Create a cancellable context + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Handle signals in a goroutine + go func() { + <-sigChan + cancel() + }() + + // Hide cursor and set up cleanup + termimg.HideCursor(os.Stdout) + defer termimg.CleanupLiveView(os.Stdout, false) + + // Clear screen initially + termimg.ClearScreen(os.Stdout) + + pterm.Info.Println("Live view started. Press Ctrl+C to exit.") + fmt.Println() // Add space before image + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Capture and display first frame immediately + if err := b.captureAndDisplayFrame(ctx, browser.SessionID); err != nil { + // If first frame fails, show error and exit + pterm.Error.Printf("Failed to capture screenshot: %v\n", err) + return nil + } + + // Main loop + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := b.captureAndDisplayFrame(ctx, browser.SessionID); err != nil { + // Log error but continue trying + // The browser session may have ended + if ctx.Err() != nil { + return nil + } + // Browser session likely ended + pterm.Warning.Println("\nBrowser session ended or screenshot failed") + return nil + } + } + } +} + +// captureAndDisplayFrame captures a screenshot and displays it in the terminal. +func (b BrowsersCmd) captureAndDisplayFrame(ctx context.Context, sessionID string) error { + res, err := b.computer.CaptureScreenshot(ctx, sessionID, kernel.BrowserComputerCaptureScreenshotParams{}) + if err != nil { + return err + } + defer res.Body.Close() + + imgData, err := io.ReadAll(res.Body) + if err != nil { + return err + } + + return termimg.ClearAndDisplayImage(os.Stdout, imgData) +} + func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { if in.Output != "" && in.Output != "json" { pterm.Error.Println("unsupported --output value: use 'json'") @@ -595,6 +702,7 @@ type BrowsersComputerScreenshotInput struct { Height int64 To string HasRegion bool + Display bool } type BrowsersComputerTypeTextInput struct { @@ -703,21 +811,48 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer return util.CleanedUpSdkError{Err: err} } defer res.Body.Close() - if in.To == "" { - pterm.Error.Println("--to is required to save the screenshot") - return nil - } - f, err := os.Create(in.To) + + // Read the image data into memory (needed for both display and save) + imgData, err := io.ReadAll(res.Body) if err != nil { - pterm.Error.Printf("Failed to create file: %v\n", err) + pterm.Error.Printf("Failed to read screenshot data: %v\n", err) return nil } - defer f.Close() - if _, err := io.Copy(f, res.Body); err != nil { - pterm.Error.Printf("Failed to write file: %v\n", err) + + // Must specify at least one output option + if in.To == "" && !in.Display { + pterm.Error.Println("specify --to to save to a file, --display to show inline, or both") return nil } - pterm.Success.Printf("Saved screenshot to %s\n", in.To) + + // Display inline in terminal if requested + if in.Display { + if err := termimg.DisplayImage(os.Stdout, imgData); err != nil { + // If --to was also specified, warn but continue to save the file + if in.To != "" { + pterm.Warning.Printf("Failed to display screenshot: %v\n", err) + } else { + pterm.Error.Printf("Failed to display screenshot: %v\n", err) + return nil + } + } + } + + // Save to file if requested + if in.To != "" { + f, err := os.Create(in.To) + if err != nil { + pterm.Error.Printf("Failed to create file: %v\n", err) + return nil + } + defer f.Close() + if _, err := f.Write(imgData); err != nil { + pterm.Error.Printf("Failed to write file: %v\n", err) + return nil + } + pterm.Success.Printf("Saved screenshot to %s\n", in.To) + } + return nil } @@ -1732,7 +1867,7 @@ var browsersDeleteCmd = &cobra.Command{ var browsersViewCmd = &cobra.Command{ Use: "view ", - Short: "Get the live view URL for a browser", + Short: "Get the live view URL for a browser, or show a live terminal view", Args: cobra.ExactArgs(1), RunE: runBrowsersView, } @@ -1755,6 +1890,10 @@ func init() { // get flags browsersGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response") + // view flags + browsersViewCmd.Flags().Bool("live", false, "Show live terminal view of browser (requires iTerm2, Kitty, or Ghostty)") + browsersViewCmd.Flags().Duration("interval", 100*time.Millisecond, "Refresh interval for live view") + browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) browsersCmd.AddCommand(browsersDeleteCmd) @@ -1904,7 +2043,7 @@ func init() { computerScreenshot.Flags().Int64("width", 0, "Region width") computerScreenshot.Flags().Int64("height", 0, "Region height") computerScreenshot.Flags().String("to", "", "Output file path for the PNG image") - _ = computerScreenshot.MarkFlagRequired("to") + computerScreenshot.Flags().Bool("display", false, "Display screenshot inline in terminal (iTerm2/Kitty)") computerType := &cobra.Command{Use: "type ", Short: "Type text on the browser instance", Args: cobra.ExactArgs(1), RunE: runBrowsersComputerTypeText} computerType.Flags().String("text", "", "Text to type") @@ -2137,10 +2276,16 @@ func runBrowsersView(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) identifier := args[0] + live, _ := cmd.Flags().GetBool("live") + interval, _ := cmd.Flags().GetDuration("interval") - in := BrowsersViewInput{Identifier: identifier} + in := BrowsersViewInput{ + Identifier: identifier, + Live: live, + Interval: interval, + } svc := client.Browsers - b := BrowsersCmd{browsers: &svc} + b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} return b.View(cmd.Context(), in) } @@ -2459,6 +2604,7 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error { w, _ := cmd.Flags().GetInt64("width") h, _ := cmd.Flags().GetInt64("height") to, _ := cmd.Flags().GetString("to") + display, _ := cmd.Flags().GetBool("display") bx := cmd.Flags().Changed("x") by := cmd.Flags().Changed("y") bw := cmd.Flags().Changed("width") @@ -2475,7 +2621,7 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error { } } b := BrowsersCmd{browsers: &svc, computer: &svc.Computer} - return b.ComputerScreenshot(cmd.Context(), BrowsersComputerScreenshotInput{Identifier: args[0], X: x, Y: y, Width: w, Height: h, To: to, HasRegion: useRegion}) + return b.ComputerScreenshot(cmd.Context(), BrowsersComputerScreenshotInput{Identifier: args[0], X: x, Y: y, Width: w, Height: h, To: to, HasRegion: useRegion, Display: display}) } func runBrowsersComputerTypeText(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 2fe489d..4a8db19 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -357,6 +357,108 @@ func TestBrowsersView_PrintsErrorOnGetFailure(t *testing.T) { assert.Contains(t, err.Error(), "get error") } +func TestBrowsersLiveView_RequiresComputerService(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: "abc"}, nil + }, + } + // No computer service + b := BrowsersCmd{browsers: fake, computer: nil} + _ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc", Live: true}) + + out := outBuf.String() + assert.Contains(t, out, "computer service not available") +} + +func TestBrowsersLiveView_RequiresTerminalSupport(t *testing.T) { + setupStdoutCapture(t) + + // Unset terminal env vars to simulate unsupported terminal + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + os.Unsetenv("TERM_PROGRAM") + os.Unsetenv("KITTY_WINDOW_ID") + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: "abc"}, nil + }, + } + fakeComp := &FakeComputerService{} + b := BrowsersCmd{browsers: fake, computer: fakeComp} + _ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc", Live: true}) + + out := outBuf.String() + assert.Contains(t, out, "Terminal does not support inline images") +} + +func TestBrowsersLiveView_ExitsOnCancelledContext(t *testing.T) { + setupStdoutCapture(t) + + // Set up terminal support + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + os.Setenv("TERM_PROGRAM", "ghostty") + os.Unsetenv("KITTY_WINDOW_ID") + + fake := &FakeBrowsersService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: "abc"}, nil + }, + } + + screenshotCount := 0 + fakeComp := &FakeComputerService{ + CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { + screenshotCount++ + // Return a simple PNG-like response + return &http.Response{ + StatusCode: 200, + Header: http.Header{"Content-Type": []string{"image/png"}}, + Body: io.NopCloser(strings.NewReader("fake-png-data")), + }, nil + }, + } + + b := BrowsersCmd{browsers: fake, computer: fakeComp} + + // Create a context that we'll cancel immediately after first frame + ctx, cancel := context.WithCancel(context.Background()) + + // Run in goroutine and cancel after a short delay + done := make(chan struct{}) + go func() { + _ = b.View(ctx, BrowsersViewInput{Identifier: "abc", Live: true, Interval: 50 * time.Millisecond}) + close(done) + }() + + // Wait a bit for first frame, then cancel + time.Sleep(100 * time.Millisecond) + cancel() + + // Wait for view to exit + select { + case <-done: + // Success - view exited + case <-time.After(2 * time.Second): + t.Fatal("LiveView did not exit after context cancellation") + } + + // Should have captured at least one screenshot + assert.GreaterOrEqual(t, screenshotCount, 1) +} + func TestBrowsersGet_PrintsDetails(t *testing.T) { setupStdoutCapture(t) @@ -1060,6 +1162,41 @@ func TestBrowsersComputerScreenshot_SavesFile(t *testing.T) { assert.Equal(t, "pngDATA", string(data)) } +func TestBrowsersComputerScreenshot_RequiresOutputOption(t *testing.T) { + setupStdoutCapture(t) + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + fakeComp := &FakeComputerService{CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"image/png"}}, Body: io.NopCloser(strings.NewReader("pngDATA"))}, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, computer: fakeComp} + // Neither --to nor --display specified + _ = b.ComputerScreenshot(context.Background(), BrowsersComputerScreenshotInput{Identifier: "id"}) + out := outBuf.String() + assert.Contains(t, out, "specify --to to save to a file, --display to show inline, or both") +} + +func TestBrowsersComputerScreenshot_DisplayAndSave(t *testing.T) { + setupStdoutCapture(t) + // Set iTerm2 env var to enable display + origTermProgram := os.Getenv("TERM_PROGRAM") + defer os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("TERM_PROGRAM", "iTerm.app") + + dir := t.TempDir() + outPath := filepath.Join(dir, "shot.png") + fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() + fakeComp := &FakeComputerService{CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"image/png"}}, Body: io.NopCloser(strings.NewReader("pngDATA"))}, nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, computer: fakeComp} + // Both --display and --to specified + _ = b.ComputerScreenshot(context.Background(), BrowsersComputerScreenshotInput{Identifier: "id", To: outPath, Display: true}) + // File should be saved + data, err := os.ReadFile(outPath) + assert.NoError(t, err) + assert.Equal(t, "pngDATA", string(data)) +} + func TestBrowsersComputerPressKey_PrintsSuccess(t *testing.T) { setupStdoutCapture(t) fakeBrowsers := newFakeBrowsersServiceWithSimpleGet() diff --git a/go.mod b/go.mod index b17f5cc..c1717e3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/boyter/gocodewalker v1.4.0 github.com/charmbracelet/fang v0.2.0 + github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 github.com/onkernel/kernel-go-sdk v0.21.0 @@ -25,7 +26,6 @@ require ( atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect github.com/charmbracelet/colorprofile v0.3.0 // indirect - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect diff --git a/pkg/termimg/termimg.go b/pkg/termimg/termimg.go new file mode 100644 index 0000000..036f877 --- /dev/null +++ b/pkg/termimg/termimg.go @@ -0,0 +1,276 @@ +// Package termimg provides utilities for displaying images inline in terminal emulators. +// It supports iTerm2 and Kitty graphics protocols. +package termimg + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "os" + + "golang.org/x/term" +) + +// TerminalType represents the type of terminal emulator. +type TerminalType int + +const ( + TerminalUnknown TerminalType = iota + TerminaliTerm2 + TerminalKitty + TerminalGhostty +) + +func (t TerminalType) String() string { + switch t { + case TerminaliTerm2: + return "iTerm2" + case TerminalKitty: + return "Kitty" + case TerminalGhostty: + return "Ghostty" + default: + return "Unknown" + } +} + +// DetectTerminal returns the type of terminal emulator based on environment variables. +func DetectTerminal() TerminalType { + termProgram := os.Getenv("TERM_PROGRAM") + // Check for iTerm2 + if termProgram == "iTerm.app" { + return TerminaliTerm2 + } + // Check for Ghostty (uses Kitty graphics protocol) + if termProgram == "ghostty" { + return TerminalGhostty + } + // Check for Kitty + if os.Getenv("KITTY_WINDOW_ID") != "" { + return TerminalKitty + } + return TerminalUnknown +} + +// IsSupported returns true if the current terminal supports inline image display. +func IsSupported() bool { + return DetectTerminal() != TerminalUnknown +} + +// getTerminalSize returns the terminal width and height in columns and rows. +// Returns default values if the size cannot be determined. +func getTerminalSize() (cols, rows int) { + // Default to reasonable values if we can't detect + cols, rows = 80, 24 + + // Try stdout first, then stdin + for _, fd := range []int{int(os.Stdout.Fd()), int(os.Stdin.Fd())} { + if term.IsTerminal(fd) { + if w, h, err := term.GetSize(fd); err == nil { + return w, h + } + } + } + return cols, rows +} + +// DisplayImage writes escape sequences to display the given image data inline. +// The image data should be raw PNG/JPEG bytes. +func DisplayImage(w io.Writer, img []byte) error { + term := DetectTerminal() + switch term { + case TerminaliTerm2: + return displayiTerm2(w, img) + case TerminalKitty, TerminalGhostty: + // Ghostty uses the Kitty graphics protocol + return displayKitty(w, img) + default: + return fmt.Errorf("terminal does not support inline images (detected: %s). Try using iTerm2, Kitty, or Ghostty, or use --to to save to a file", term) + } +} + +// ANSI escape sequences for cursor and screen control +const ( + cursorHide = "\033[?25l" + cursorShow = "\033[?25h" + cursorHome = "\033[H" + clearToEnd = "\033[J" + clearScreen = "\033[2J" + saveCursor = "\033[s" + restoreCursor = "\033[u" + + // Synchronized output mode (supported by Kitty, Ghostty, and others) + // Buffers all output and renders atomically to prevent flicker + syncStart = "\033[?2026h" + syncEnd = "\033[?2026l" +) + +// HideCursor hides the terminal cursor. +func HideCursor(w io.Writer) { + fmt.Fprint(w, cursorHide) +} + +// ShowCursor shows the terminal cursor. +func ShowCursor(w io.Writer) { + fmt.Fprint(w, cursorShow) +} + +// ClearScreen clears the entire screen and moves cursor to home position. +func ClearScreen(w io.Writer) { + fmt.Fprint(w, cursorHome+clearScreen) +} + +// ClearAndDisplayImage clears the screen and displays an image at the top. +// This is used for animation/live view to replace the previous frame. +func ClearAndDisplayImage(w io.Writer, img []byte) error { + term := DetectTerminal() + switch term { + case TerminaliTerm2: + // For iTerm2: move cursor home, clear screen, then draw + fmt.Fprint(w, cursorHome+clearToEnd) + return displayiTerm2(w, img) + case TerminalKitty, TerminalGhostty: + // For Kitty: delete previous image by ID, then draw new one with same ID + return displayKittyFrame(w, img) + default: + return fmt.Errorf("terminal does not support inline images (detected: %s). Try using iTerm2, Kitty, or Ghostty", term) + } +} + +// liveViewImageID is the placement ID used for live view frames +const liveViewImageID = 1 + +// displayKittyFrame displays an image for live view, replacing any previous frame. +// Uses synchronized output mode to prevent flicker - all drawing is buffered +// and rendered atomically. +func displayKittyFrame(w io.Writer, img []byte) error { + encoded := base64.StdEncoding.EncodeToString(img) + cols, _ := getTerminalSize() + + // Buffer all output to write in one go + var buf bytes.Buffer + + // Start synchronized output mode - terminal buffers all changes + buf.WriteString(syncStart) + + // Delete the previous frame with this ID (ignore if none exists) + // a=d means delete, d=i means delete by image ID, i=ID specifies the ID + // q=2 quiet mode - suppress response + fmt.Fprintf(&buf, "\033_Ga=d,d=i,q=2,i=%d\033\\", liveViewImageID) + + // Move cursor to home position for consistent placement + buf.WriteString(cursorHome) + + const chunkSize = 4096 + + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunk := encoded[i:end] + + more := 1 + if end >= len(encoded) { + more = 0 + } + + if i == 0 { + // First chunk: include all parameters + // a=T transmit and display, f=100 PNG, i=ID placement ID, c=cols width + // q=2 quiet mode - suppress response to avoid printing "_Gi=1;OK" garbage + fmt.Fprintf(&buf, "\033_Ga=T,q=2,f=100,i=%d,c=%d,m=%d;%s\033\\", liveViewImageID, cols, more, chunk) + } else { + fmt.Fprintf(&buf, "\033_Gm=%d;%s\033\\", more, chunk) + } + } + + // End synchronized output mode - terminal renders everything atomically + buf.WriteString(syncEnd) + + // Write everything in one go + _, err := w.Write(buf.Bytes()) + return err +} + +// CleanupLiveView cleans up after a live view session. +// It shows the cursor and optionally clears the last frame. +func CleanupLiveView(w io.Writer, clearImage bool) { + term := DetectTerminal() + + // For Kitty/Ghostty, delete the live view image + if clearImage && (term == TerminalKitty || term == TerminalGhostty) { + fmt.Fprintf(w, "\033_Ga=d,d=i,q=2,i=%d\033\\", liveViewImageID) + } + + // Show cursor again + ShowCursor(w) + + // Print newline to ensure prompt appears on clean line + fmt.Fprintln(w) +} + +// displayiTerm2 renders an image using iTerm2's inline images protocol. +// Protocol: ESC ] 1337 ; File = [args] : base64data BEL +// https://iterm2.com/documentation-images.html +func displayiTerm2(w io.Writer, img []byte) error { + encoded := base64.StdEncoding.EncodeToString(img) + // inline=1 displays the image inline + // width=100% fills terminal width, height=auto preserves aspect ratio + // preserveAspectRatio=1 maintains aspect ratio + _, err := fmt.Fprintf(w, "\033]1337;File=inline=1;width=100%%;height=auto;preserveAspectRatio=1:%s\a", encoded) + return err +} + +// displayKitty renders an image using Kitty's graphics protocol. +// Protocol uses chunked transmission for large images. +// https://sw.kovidgoyal.net/kitty/graphics-protocol/ +func displayKitty(w io.Writer, img []byte) error { + encoded := base64.StdEncoding.EncodeToString(img) + + // Get terminal width to scale image appropriately + cols, _ := getTerminalSize() + + // Kitty requires chunked transmission for data over 4096 bytes + const chunkSize = 4096 + + for i := 0; i < len(encoded); i += chunkSize { + end := i + chunkSize + if end > len(encoded) { + end = len(encoded) + } + chunk := encoded[i:end] + + // m=1 means more chunks coming, m=0 means last chunk + // a=T means transmit and display + // f=100 means PNG format (also works for JPEG) + // c=cols means display width in terminal columns + // q=2 quiet mode - suppress response to avoid printing garbage + if i == 0 { + // First chunk includes all the parameters + more := 1 + if end >= len(encoded) { + more = 0 + } + _, err := fmt.Fprintf(w, "\033_Ga=T,q=2,f=100,c=%d,m=%d;%s\033\\", cols, more, chunk) + if err != nil { + return err + } + } else { + // Subsequent chunks only need the 'm' parameter + more := 1 + if end >= len(encoded) { + more = 0 + } + _, err := fmt.Fprintf(w, "\033_Gm=%d;%s\033\\", more, chunk) + if err != nil { + return err + } + } + } + + // Print a newline after the image so subsequent output appears below + _, err := fmt.Fprintln(w) + return err +} diff --git a/pkg/termimg/termimg_test.go b/pkg/termimg/termimg_test.go new file mode 100644 index 0000000..ddd2ed4 --- /dev/null +++ b/pkg/termimg/termimg_test.go @@ -0,0 +1,287 @@ +package termimg + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTerminalType_String(t *testing.T) { + tests := []struct { + term TerminalType + expected string + }{ + {TerminalUnknown, "Unknown"}, + {TerminaliTerm2, "iTerm2"}, + {TerminalKitty, "Kitty"}, + {TerminalGhostty, "Ghostty"}, + } + for _, tt := range tests { + assert.Equal(t, tt.expected, tt.term.String()) + } +} + +func TestDetectTerminal_iTerm2(t *testing.T) { + // Save and restore env vars + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Setenv("TERM_PROGRAM", "iTerm.app") + os.Unsetenv("KITTY_WINDOW_ID") + + assert.Equal(t, TerminaliTerm2, DetectTerminal()) + assert.True(t, IsSupported()) +} + +func TestDetectTerminal_Kitty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Setenv("KITTY_WINDOW_ID", "12345") + + assert.Equal(t, TerminalKitty, DetectTerminal()) + assert.True(t, IsSupported()) +} + +func TestDetectTerminal_Ghostty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Setenv("TERM_PROGRAM", "ghostty") + os.Unsetenv("KITTY_WINDOW_ID") + + assert.Equal(t, TerminalGhostty, DetectTerminal()) + assert.True(t, IsSupported()) +} + +func TestDetectTerminal_Unknown(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Unsetenv("KITTY_WINDOW_ID") + + assert.Equal(t, TerminalUnknown, DetectTerminal()) + assert.False(t, IsSupported()) +} + +func TestDisplayImage_UnsupportedTerminal(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Unsetenv("KITTY_WINDOW_ID") + + var buf bytes.Buffer + err := DisplayImage(&buf, []byte("fake image data")) + + require.Error(t, err) + assert.Contains(t, err.Error(), "terminal does not support inline images") +} + +func TestDisplayImage_iTerm2(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Setenv("TERM_PROGRAM", "iTerm.app") + os.Unsetenv("KITTY_WINDOW_ID") + + var buf bytes.Buffer + imgData := []byte("test png data") + err := DisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Should contain iTerm2 escape sequence with 100% width to fill terminal + assert.Contains(t, output, "\033]1337;File=inline=1;width=100%;height=auto;preserveAspectRatio=1:") + // Should end with bell character + assert.True(t, output[len(output)-1] == '\a') +} + +func TestDisplayImage_Kitty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Setenv("KITTY_WINDOW_ID", "12345") + + var buf bytes.Buffer + imgData := []byte("test png data") + err := DisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Should contain Kitty escape sequence prefix with quiet mode + assert.Contains(t, output, "\033_Ga=T,q=2,f=100") +} + +func TestDisplayImage_Ghostty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Setenv("TERM_PROGRAM", "ghostty") + os.Unsetenv("KITTY_WINDOW_ID") + + var buf bytes.Buffer + imgData := []byte("test png data") + err := DisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Ghostty uses Kitty protocol, should contain Kitty escape sequence prefix with quiet mode + assert.Contains(t, output, "\033_Ga=T,q=2,f=100") +} + +func TestDisplayImage_Kitty_LargeImage(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Setenv("KITTY_WINDOW_ID", "12345") + + var buf bytes.Buffer + // Create data that will result in > 4096 bytes when base64 encoded + // (4096 * 3/4 = 3072 raw bytes, so use more) + imgData := make([]byte, 5000) + for i := range imgData { + imgData[i] = byte(i % 256) + } + err := DisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Should have multiple chunks indicated by m=1 (more) followed by m=0 (last) + assert.Contains(t, output, "m=1") + assert.Contains(t, output, "m=0") +} + +func TestHideCursor(t *testing.T) { + var buf bytes.Buffer + HideCursor(&buf) + assert.Equal(t, "\033[?25l", buf.String()) +} + +func TestShowCursor(t *testing.T) { + var buf bytes.Buffer + ShowCursor(&buf) + assert.Equal(t, "\033[?25h", buf.String()) +} + +func TestClearScreen(t *testing.T) { + var buf bytes.Buffer + ClearScreen(&buf) + assert.Equal(t, "\033[H\033[2J", buf.String()) +} + +func TestClearAndDisplayImage_iTerm2(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Setenv("TERM_PROGRAM", "iTerm.app") + os.Unsetenv("KITTY_WINDOW_ID") + + var buf bytes.Buffer + imgData := []byte("test png data") + err := ClearAndDisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Should start with cursor home and clear + assert.True(t, len(output) > 0 && output[0] == '\033') + assert.Contains(t, output, "\033[H\033[J") + // Should contain iTerm2 image escape + assert.Contains(t, output, "\033]1337;File=inline=1") +} + +func TestClearAndDisplayImage_Kitty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Setenv("KITTY_WINDOW_ID", "12345") + + var buf bytes.Buffer + imgData := []byte("test png data") + err := ClearAndDisplayImage(&buf, imgData) + + require.NoError(t, err) + output := buf.String() + // Should start with synchronized output mode + assert.Contains(t, output, "\033[?2026h") + // Should contain delete command for previous image (with q=2 quiet mode) + assert.Contains(t, output, "\033_Ga=d,d=i,q=2,i=1\033\\") + // Should contain Kitty image with placement ID and quiet mode + assert.Contains(t, output, "q=2") + assert.Contains(t, output, "i=1") + // Should end synchronized output mode + assert.Contains(t, output, "\033[?2026l") +} + +func TestCleanupLiveView_Kitty(t *testing.T) { + origTermProgram := os.Getenv("TERM_PROGRAM") + origKittyID := os.Getenv("KITTY_WINDOW_ID") + defer func() { + os.Setenv("TERM_PROGRAM", origTermProgram) + os.Setenv("KITTY_WINDOW_ID", origKittyID) + }() + + os.Unsetenv("TERM_PROGRAM") + os.Setenv("KITTY_WINDOW_ID", "12345") + + var buf bytes.Buffer + CleanupLiveView(&buf, true) + + output := buf.String() + // Should delete the image (with q=2 quiet mode) + assert.Contains(t, output, "\033_Ga=d,d=i,q=2,i=1\033\\") + // Should show cursor + assert.Contains(t, output, "\033[?25h") +}