Skip to content

Commit 4682cdf

Browse files
committed
live view
1 parent c201a84 commit 4682cdf

4 files changed

Lines changed: 446 additions & 10 deletions

File tree

cmd/browsers.go

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@ import (
1010
"math/big"
1111
"net/http"
1212
"os"
13+
"os/signal"
1314
"path/filepath"
1415
"regexp"
1516
"strconv"
1617
"strings"
18+
"syscall"
19+
"time"
1720

1821
"github.com/onkernel/cli/pkg/termimg"
1922
"github.com/onkernel/cli/pkg/util"
@@ -173,6 +176,8 @@ type BrowsersDeleteInput struct {
173176

174177
type BrowsersViewInput struct {
175178
Identifier string
179+
Live bool
180+
Interval time.Duration
176181
}
177182

178183
type BrowsersGetInput struct {
@@ -457,6 +462,11 @@ func (b BrowsersCmd) Delete(ctx context.Context, in BrowsersDeleteInput) error {
457462
}
458463

459464
func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error {
465+
// If live view requested, run the live view loop
466+
if in.Live {
467+
return b.LiveView(ctx, in)
468+
}
469+
460470
browser, err := b.browsers.Get(ctx, in.Identifier)
461471
if err != nil {
462472
return util.CleanedUpSdkError{Err: err}
@@ -474,6 +484,102 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error {
474484
return nil
475485
}
476486

487+
// LiveView displays a continuously updating view of the browser in the terminal.
488+
func (b BrowsersCmd) LiveView(ctx context.Context, in BrowsersViewInput) error {
489+
if b.computer == nil {
490+
pterm.Error.Println("computer service not available")
491+
return nil
492+
}
493+
494+
// Check terminal supports inline images
495+
if !termimg.IsSupported() {
496+
pterm.Error.Printf("Terminal does not support inline images (detected: %s). Try using iTerm2, Kitty, or Ghostty.\n", termimg.DetectTerminal())
497+
return nil
498+
}
499+
500+
// Verify browser exists and get session ID
501+
browser, err := b.browsers.Get(ctx, in.Identifier)
502+
if err != nil {
503+
return util.CleanedUpSdkError{Err: err}
504+
}
505+
506+
// Set default interval if not specified
507+
interval := in.Interval
508+
if interval == 0 {
509+
interval = 100 * time.Millisecond
510+
}
511+
512+
// Set up signal handling for graceful exit
513+
sigChan := make(chan os.Signal, 1)
514+
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
515+
defer signal.Stop(sigChan)
516+
517+
// Create a cancellable context
518+
ctx, cancel := context.WithCancel(ctx)
519+
defer cancel()
520+
521+
// Handle signals in a goroutine
522+
go func() {
523+
<-sigChan
524+
cancel()
525+
}()
526+
527+
// Hide cursor and set up cleanup
528+
termimg.HideCursor(os.Stdout)
529+
defer termimg.CleanupLiveView(os.Stdout, false)
530+
531+
// Clear screen initially
532+
termimg.ClearScreen(os.Stdout)
533+
534+
pterm.Info.Println("Live view started. Press Ctrl+C to exit.")
535+
fmt.Println() // Add space before image
536+
537+
ticker := time.NewTicker(interval)
538+
defer ticker.Stop()
539+
540+
// Capture and display first frame immediately
541+
if err := b.captureAndDisplayFrame(ctx, browser.SessionID); err != nil {
542+
// If first frame fails, show error and exit
543+
pterm.Error.Printf("Failed to capture screenshot: %v\n", err)
544+
return nil
545+
}
546+
547+
// Main loop
548+
for {
549+
select {
550+
case <-ctx.Done():
551+
return nil
552+
case <-ticker.C:
553+
if err := b.captureAndDisplayFrame(ctx, browser.SessionID); err != nil {
554+
// Log error but continue trying
555+
// The browser session may have ended
556+
if ctx.Err() != nil {
557+
return nil
558+
}
559+
// Browser session likely ended
560+
pterm.Warning.Println("\nBrowser session ended or screenshot failed")
561+
return nil
562+
}
563+
}
564+
}
565+
}
566+
567+
// captureAndDisplayFrame captures a screenshot and displays it in the terminal.
568+
func (b BrowsersCmd) captureAndDisplayFrame(ctx context.Context, sessionID string) error {
569+
res, err := b.computer.CaptureScreenshot(ctx, sessionID, kernel.BrowserComputerCaptureScreenshotParams{})
570+
if err != nil {
571+
return err
572+
}
573+
defer res.Body.Close()
574+
575+
imgData, err := io.ReadAll(res.Body)
576+
if err != nil {
577+
return err
578+
}
579+
580+
return termimg.ClearAndDisplayImage(os.Stdout, imgData)
581+
}
582+
477583
func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error {
478584
if in.Output != "" && in.Output != "json" {
479585
pterm.Error.Println("unsupported --output value: use 'json'")
@@ -1756,7 +1862,7 @@ var browsersDeleteCmd = &cobra.Command{
17561862

17571863
var browsersViewCmd = &cobra.Command{
17581864
Use: "view <id>",
1759-
Short: "Get the live view URL for a browser",
1865+
Short: "Get the live view URL for a browser, or show a live terminal view",
17601866
Args: cobra.ExactArgs(1),
17611867
RunE: runBrowsersView,
17621868
}
@@ -1779,6 +1885,10 @@ func init() {
17791885
// get flags
17801886
browsersGetCmd.Flags().StringP("output", "o", "", "Output format: json for raw API response")
17811887

1888+
// view flags
1889+
browsersViewCmd.Flags().Bool("live", false, "Show live terminal view of browser (requires iTerm2, Kitty, or Ghostty)")
1890+
browsersViewCmd.Flags().Duration("interval", 100*time.Millisecond, "Refresh interval for live view")
1891+
17821892
browsersCmd.AddCommand(browsersListCmd)
17831893
browsersCmd.AddCommand(browsersCreateCmd)
17841894
browsersCmd.AddCommand(browsersDeleteCmd)
@@ -2161,10 +2271,16 @@ func runBrowsersView(cmd *cobra.Command, args []string) error {
21612271
client := getKernelClient(cmd)
21622272

21632273
identifier := args[0]
2274+
live, _ := cmd.Flags().GetBool("live")
2275+
interval, _ := cmd.Flags().GetDuration("interval")
21642276

2165-
in := BrowsersViewInput{Identifier: identifier}
2277+
in := BrowsersViewInput{
2278+
Identifier: identifier,
2279+
Live: live,
2280+
Interval: interval,
2281+
}
21662282
svc := client.Browsers
2167-
b := BrowsersCmd{browsers: &svc}
2283+
b := BrowsersCmd{browsers: &svc, computer: &svc.Computer}
21682284
return b.View(cmd.Context(), in)
21692285
}
21702286

cmd/browsers_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,108 @@ func TestBrowsersView_PrintsErrorOnGetFailure(t *testing.T) {
357357
assert.Contains(t, err.Error(), "get error")
358358
}
359359

360+
func TestBrowsersLiveView_RequiresComputerService(t *testing.T) {
361+
setupStdoutCapture(t)
362+
363+
fake := &FakeBrowsersService{
364+
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
365+
return &kernel.BrowserGetResponse{SessionID: "abc"}, nil
366+
},
367+
}
368+
// No computer service
369+
b := BrowsersCmd{browsers: fake, computer: nil}
370+
_ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc", Live: true})
371+
372+
out := outBuf.String()
373+
assert.Contains(t, out, "computer service not available")
374+
}
375+
376+
func TestBrowsersLiveView_RequiresTerminalSupport(t *testing.T) {
377+
setupStdoutCapture(t)
378+
379+
// Unset terminal env vars to simulate unsupported terminal
380+
origTermProgram := os.Getenv("TERM_PROGRAM")
381+
origKittyID := os.Getenv("KITTY_WINDOW_ID")
382+
defer func() {
383+
os.Setenv("TERM_PROGRAM", origTermProgram)
384+
os.Setenv("KITTY_WINDOW_ID", origKittyID)
385+
}()
386+
os.Unsetenv("TERM_PROGRAM")
387+
os.Unsetenv("KITTY_WINDOW_ID")
388+
389+
fake := &FakeBrowsersService{
390+
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
391+
return &kernel.BrowserGetResponse{SessionID: "abc"}, nil
392+
},
393+
}
394+
fakeComp := &FakeComputerService{}
395+
b := BrowsersCmd{browsers: fake, computer: fakeComp}
396+
_ = b.View(context.Background(), BrowsersViewInput{Identifier: "abc", Live: true})
397+
398+
out := outBuf.String()
399+
assert.Contains(t, out, "Terminal does not support inline images")
400+
}
401+
402+
func TestBrowsersLiveView_ExitsOnCancelledContext(t *testing.T) {
403+
setupStdoutCapture(t)
404+
405+
// Set up terminal support
406+
origTermProgram := os.Getenv("TERM_PROGRAM")
407+
origKittyID := os.Getenv("KITTY_WINDOW_ID")
408+
defer func() {
409+
os.Setenv("TERM_PROGRAM", origTermProgram)
410+
os.Setenv("KITTY_WINDOW_ID", origKittyID)
411+
}()
412+
os.Setenv("TERM_PROGRAM", "ghostty")
413+
os.Unsetenv("KITTY_WINDOW_ID")
414+
415+
fake := &FakeBrowsersService{
416+
GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
417+
return &kernel.BrowserGetResponse{SessionID: "abc"}, nil
418+
},
419+
}
420+
421+
screenshotCount := 0
422+
fakeComp := &FakeComputerService{
423+
CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) {
424+
screenshotCount++
425+
// Return a simple PNG-like response
426+
return &http.Response{
427+
StatusCode: 200,
428+
Header: http.Header{"Content-Type": []string{"image/png"}},
429+
Body: io.NopCloser(strings.NewReader("fake-png-data")),
430+
}, nil
431+
},
432+
}
433+
434+
b := BrowsersCmd{browsers: fake, computer: fakeComp}
435+
436+
// Create a context that we'll cancel immediately after first frame
437+
ctx, cancel := context.WithCancel(context.Background())
438+
439+
// Run in goroutine and cancel after a short delay
440+
done := make(chan struct{})
441+
go func() {
442+
_ = b.View(ctx, BrowsersViewInput{Identifier: "abc", Live: true, Interval: 50 * time.Millisecond})
443+
close(done)
444+
}()
445+
446+
// Wait a bit for first frame, then cancel
447+
time.Sleep(100 * time.Millisecond)
448+
cancel()
449+
450+
// Wait for view to exit
451+
select {
452+
case <-done:
453+
// Success - view exited
454+
case <-time.After(2 * time.Second):
455+
t.Fatal("LiveView did not exit after context cancellation")
456+
}
457+
458+
// Should have captured at least one screenshot
459+
assert.GreaterOrEqual(t, screenshotCount, 1)
460+
}
461+
360462
func TestBrowsersGet_PrintsDetails(t *testing.T) {
361463
setupStdoutCapture(t)
362464

0 commit comments

Comments
 (0)