Skip to content

Commit c201a84

Browse files
committed
screenshot to terminal
1 parent 8a78691 commit c201a84

File tree

5 files changed

+418
-13
lines changed

5 files changed

+418
-13
lines changed

cmd/browsers.go

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"strconv"
1616
"strings"
1717

18+
"github.com/onkernel/cli/pkg/termimg"
1819
"github.com/onkernel/cli/pkg/util"
1920
"github.com/onkernel/kernel-go-sdk"
2021
"github.com/onkernel/kernel-go-sdk/option"
@@ -595,6 +596,7 @@ type BrowsersComputerScreenshotInput struct {
595596
Height int64
596597
To string
597598
HasRegion bool
599+
Display bool
598600
}
599601

600602
type BrowsersComputerTypeTextInput struct {
@@ -703,21 +705,43 @@ func (b BrowsersCmd) ComputerScreenshot(ctx context.Context, in BrowsersComputer
703705
return util.CleanedUpSdkError{Err: err}
704706
}
705707
defer res.Body.Close()
706-
if in.To == "" {
707-
pterm.Error.Println("--to is required to save the screenshot")
708-
return nil
709-
}
710-
f, err := os.Create(in.To)
708+
709+
// Read the image data into memory (needed for both display and save)
710+
imgData, err := io.ReadAll(res.Body)
711711
if err != nil {
712-
pterm.Error.Printf("Failed to create file: %v\n", err)
712+
pterm.Error.Printf("Failed to read screenshot data: %v\n", err)
713713
return nil
714714
}
715-
defer f.Close()
716-
if _, err := io.Copy(f, res.Body); err != nil {
717-
pterm.Error.Printf("Failed to write file: %v\n", err)
715+
716+
// Must specify at least one output option
717+
if in.To == "" && !in.Display {
718+
pterm.Error.Println("specify --to to save to a file, --display to show inline, or both")
718719
return nil
719720
}
720-
pterm.Success.Printf("Saved screenshot to %s\n", in.To)
721+
722+
// Display inline in terminal if requested
723+
if in.Display {
724+
if err := termimg.DisplayImage(os.Stdout, imgData); err != nil {
725+
pterm.Error.Printf("Failed to display screenshot: %v\n", err)
726+
return nil
727+
}
728+
}
729+
730+
// Save to file if requested
731+
if in.To != "" {
732+
f, err := os.Create(in.To)
733+
if err != nil {
734+
pterm.Error.Printf("Failed to create file: %v\n", err)
735+
return nil
736+
}
737+
defer f.Close()
738+
if _, err := f.Write(imgData); err != nil {
739+
pterm.Error.Printf("Failed to write file: %v\n", err)
740+
return nil
741+
}
742+
pterm.Success.Printf("Saved screenshot to %s\n", in.To)
743+
}
744+
721745
return nil
722746
}
723747

@@ -1904,7 +1928,7 @@ func init() {
19041928
computerScreenshot.Flags().Int64("width", 0, "Region width")
19051929
computerScreenshot.Flags().Int64("height", 0, "Region height")
19061930
computerScreenshot.Flags().String("to", "", "Output file path for the PNG image")
1907-
_ = computerScreenshot.MarkFlagRequired("to")
1931+
computerScreenshot.Flags().Bool("display", false, "Display screenshot inline in terminal (iTerm2/Kitty)")
19081932

19091933
computerType := &cobra.Command{Use: "type <id>", Short: "Type text on the browser instance", Args: cobra.ExactArgs(1), RunE: runBrowsersComputerTypeText}
19101934
computerType.Flags().String("text", "", "Text to type")
@@ -2459,6 +2483,7 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error {
24592483
w, _ := cmd.Flags().GetInt64("width")
24602484
h, _ := cmd.Flags().GetInt64("height")
24612485
to, _ := cmd.Flags().GetString("to")
2486+
display, _ := cmd.Flags().GetBool("display")
24622487
bx := cmd.Flags().Changed("x")
24632488
by := cmd.Flags().Changed("y")
24642489
bw := cmd.Flags().Changed("width")
@@ -2475,7 +2500,7 @@ func runBrowsersComputerScreenshot(cmd *cobra.Command, args []string) error {
24752500
}
24762501
}
24772502
b := BrowsersCmd{browsers: &svc, computer: &svc.Computer}
2478-
return b.ComputerScreenshot(cmd.Context(), BrowsersComputerScreenshotInput{Identifier: args[0], X: x, Y: y, Width: w, Height: h, To: to, HasRegion: useRegion})
2503+
return b.ComputerScreenshot(cmd.Context(), BrowsersComputerScreenshotInput{Identifier: args[0], X: x, Y: y, Width: w, Height: h, To: to, HasRegion: useRegion, Display: display})
24792504
}
24802505

24812506
func runBrowsersComputerTypeText(cmd *cobra.Command, args []string) error {

cmd/browsers_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1060,6 +1060,41 @@ func TestBrowsersComputerScreenshot_SavesFile(t *testing.T) {
10601060
assert.Equal(t, "pngDATA", string(data))
10611061
}
10621062

1063+
func TestBrowsersComputerScreenshot_RequiresOutputOption(t *testing.T) {
1064+
setupStdoutCapture(t)
1065+
fakeBrowsers := newFakeBrowsersServiceWithSimpleGet()
1066+
fakeComp := &FakeComputerService{CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) {
1067+
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"image/png"}}, Body: io.NopCloser(strings.NewReader("pngDATA"))}, nil
1068+
}}
1069+
b := BrowsersCmd{browsers: fakeBrowsers, computer: fakeComp}
1070+
// Neither --to nor --display specified
1071+
_ = b.ComputerScreenshot(context.Background(), BrowsersComputerScreenshotInput{Identifier: "id"})
1072+
out := outBuf.String()
1073+
assert.Contains(t, out, "specify --to to save to a file, --display to show inline, or both")
1074+
}
1075+
1076+
func TestBrowsersComputerScreenshot_DisplayAndSave(t *testing.T) {
1077+
setupStdoutCapture(t)
1078+
// Set iTerm2 env var to enable display
1079+
origTermProgram := os.Getenv("TERM_PROGRAM")
1080+
defer os.Setenv("TERM_PROGRAM", origTermProgram)
1081+
os.Setenv("TERM_PROGRAM", "iTerm.app")
1082+
1083+
dir := t.TempDir()
1084+
outPath := filepath.Join(dir, "shot.png")
1085+
fakeBrowsers := newFakeBrowsersServiceWithSimpleGet()
1086+
fakeComp := &FakeComputerService{CaptureScreenshotFunc: func(ctx context.Context, id string, body kernel.BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) {
1087+
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"image/png"}}, Body: io.NopCloser(strings.NewReader("pngDATA"))}, nil
1088+
}}
1089+
b := BrowsersCmd{browsers: fakeBrowsers, computer: fakeComp}
1090+
// Both --display and --to specified
1091+
_ = b.ComputerScreenshot(context.Background(), BrowsersComputerScreenshotInput{Identifier: "id", To: outPath, Display: true})
1092+
// File should be saved
1093+
data, err := os.ReadFile(outPath)
1094+
assert.NoError(t, err)
1095+
assert.Equal(t, "pngDATA", string(data))
1096+
}
1097+
10631098
func TestBrowsersComputerPressKey_PrintsSuccess(t *testing.T) {
10641099
setupStdoutCapture(t)
10651100
fakeBrowsers := newFakeBrowsersServiceWithSimpleGet()

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/Masterminds/semver/v3 v3.4.0
77
github.com/boyter/gocodewalker v1.4.0
88
github.com/charmbracelet/fang v0.2.0
9+
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
910
github.com/golang-jwt/jwt/v5 v5.2.2
1011
github.com/joho/godotenv v1.5.1
1112
github.com/onkernel/kernel-go-sdk v0.21.0
@@ -25,7 +26,6 @@ require (
2526
atomicgo.dev/keyboard v0.2.9 // indirect
2627
atomicgo.dev/schedule v0.1.0 // indirect
2728
github.com/charmbracelet/colorprofile v0.3.0 // indirect
28-
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 // indirect
2929
github.com/charmbracelet/x/ansi v0.8.0 // indirect
3030
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
3131
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 // indirect

pkg/termimg/termimg.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Package termimg provides utilities for displaying images inline in terminal emulators.
2+
// It supports iTerm2 and Kitty graphics protocols.
3+
package termimg
4+
5+
import (
6+
"encoding/base64"
7+
"fmt"
8+
"io"
9+
"os"
10+
11+
"golang.org/x/term"
12+
)
13+
14+
// TerminalType represents the type of terminal emulator.
15+
type TerminalType int
16+
17+
const (
18+
TerminalUnknown TerminalType = iota
19+
TerminaliTerm2
20+
TerminalKitty
21+
TerminalGhostty
22+
)
23+
24+
func (t TerminalType) String() string {
25+
switch t {
26+
case TerminaliTerm2:
27+
return "iTerm2"
28+
case TerminalKitty:
29+
return "Kitty"
30+
case TerminalGhostty:
31+
return "Ghostty"
32+
default:
33+
return "Unknown"
34+
}
35+
}
36+
37+
// DetectTerminal returns the type of terminal emulator based on environment variables.
38+
func DetectTerminal() TerminalType {
39+
termProgram := os.Getenv("TERM_PROGRAM")
40+
// Check for iTerm2
41+
if termProgram == "iTerm.app" {
42+
return TerminaliTerm2
43+
}
44+
// Check for Ghostty (uses Kitty graphics protocol)
45+
if termProgram == "ghostty" {
46+
return TerminalGhostty
47+
}
48+
// Check for Kitty
49+
if os.Getenv("KITTY_WINDOW_ID") != "" {
50+
return TerminalKitty
51+
}
52+
return TerminalUnknown
53+
}
54+
55+
// IsSupported returns true if the current terminal supports inline image display.
56+
func IsSupported() bool {
57+
return DetectTerminal() != TerminalUnknown
58+
}
59+
60+
// getTerminalSize returns the terminal width and height in columns and rows.
61+
// Returns default values if the size cannot be determined.
62+
func getTerminalSize() (cols, rows int) {
63+
// Default to reasonable values if we can't detect
64+
cols, rows = 80, 24
65+
66+
// Try stdout first, then stdin
67+
for _, fd := range []int{int(os.Stdout.Fd()), int(os.Stdin.Fd())} {
68+
if term.IsTerminal(fd) {
69+
if w, h, err := term.GetSize(fd); err == nil {
70+
return w, h
71+
}
72+
}
73+
}
74+
return cols, rows
75+
}
76+
77+
// DisplayImage writes escape sequences to display the given image data inline.
78+
// The image data should be raw PNG/JPEG bytes.
79+
func DisplayImage(w io.Writer, img []byte) error {
80+
term := DetectTerminal()
81+
switch term {
82+
case TerminaliTerm2:
83+
return displayiTerm2(w, img)
84+
case TerminalKitty, TerminalGhostty:
85+
// Ghostty uses the Kitty graphics protocol
86+
return displayKitty(w, img)
87+
default:
88+
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)
89+
}
90+
}
91+
92+
// displayiTerm2 renders an image using iTerm2's inline images protocol.
93+
// Protocol: ESC ] 1337 ; File = [args] : base64data BEL
94+
// https://iterm2.com/documentation-images.html
95+
func displayiTerm2(w io.Writer, img []byte) error {
96+
encoded := base64.StdEncoding.EncodeToString(img)
97+
// inline=1 displays the image inline
98+
// width=100% fills terminal width, height=auto preserves aspect ratio
99+
// preserveAspectRatio=1 maintains aspect ratio
100+
_, err := fmt.Fprintf(w, "\033]1337;File=inline=1;width=100%%;height=auto;preserveAspectRatio=1:%s\a", encoded)
101+
return err
102+
}
103+
104+
// displayKitty renders an image using Kitty's graphics protocol.
105+
// Protocol uses chunked transmission for large images.
106+
// https://sw.kovidgoyal.net/kitty/graphics-protocol/
107+
func displayKitty(w io.Writer, img []byte) error {
108+
encoded := base64.StdEncoding.EncodeToString(img)
109+
110+
// Kitty requires chunked transmission for data over 4096 bytes
111+
const chunkSize = 4096
112+
113+
for i := 0; i < len(encoded); i += chunkSize {
114+
end := i + chunkSize
115+
if end > len(encoded) {
116+
end = len(encoded)
117+
}
118+
chunk := encoded[i:end]
119+
120+
// m=1 means more chunks coming, m=0 means last chunk
121+
// a=T means transmit and display
122+
// f=100 means PNG format (also works for JPEG)
123+
if i == 0 {
124+
// First chunk includes all the parameters
125+
more := 1
126+
if end >= len(encoded) {
127+
more = 0
128+
}
129+
_, err := fmt.Fprintf(w, "\033_Ga=T,f=100,m=%d;%s\033\\", more, chunk)
130+
if err != nil {
131+
return err
132+
}
133+
} else {
134+
// Subsequent chunks only need the 'm' parameter
135+
more := 1
136+
if end >= len(encoded) {
137+
more = 0
138+
}
139+
_, err := fmt.Fprintf(w, "\033_Gm=%d;%s\033\\", more, chunk)
140+
if err != nil {
141+
return err
142+
}
143+
}
144+
}
145+
146+
// Print a newline after the image so subsequent output appears below
147+
_, err := fmt.Fprintln(w)
148+
return err
149+
}

0 commit comments

Comments
 (0)