diff --git a/README.md b/README.md index 79ee2181..ba032396 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,8 @@ During an interactive session, use these slash commands: | Shortcut | Description | |----------|-------------| +| `Ctrl+V` | Paste an image from the clipboard — shows an inline low-res thumbnail preview (tmux/zellij-safe) | +| `Ctrl+U` | Clear all pending image attachments | | `Ctrl+X e` | Open `$VISUAL`/`$EDITOR` to compose or edit your prompt | | `Ctrl+X s` | Steer — inject a system-level instruction mid-turn | | `ESC ESC` | Cancel the current operation (tool call or streaming) | diff --git a/go.mod b/go.mod index d863cc63..0f9acf44 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/alecthomas/chroma/v2 v2.26.1 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.4.1 + github.com/charmbracelet/colorprofile v0.4.3 github.com/charmbracelet/fang v1.0.0 github.com/charmbracelet/log v1.0.0 github.com/charmbracelet/openai-go v0.0.0-20260319145158-d0740cc34266 @@ -26,6 +27,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/traefik/yaegi v0.16.1 + golang.org/x/image v0.41.0 golang.org/x/term v0.43.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -55,7 +57,6 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/anthropic-sdk-go v0.0.0-20260223140439-63879b0b8dab // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect diff --git a/go.sum b/go.sum index d625b824..7cb3e181 100644 --- a/go.sum +++ b/go.sum @@ -298,6 +298,8 @@ golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7 h1:cHpkPjp4TILjdZxz/O4ykwCpeS+dDqNuDGse4zgQDCk= golang.org/x/exp v0.0.0-20260528193900-50dc527dd6c7/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/ui/imagepreview/imagepreview.go b/internal/ui/imagepreview/imagepreview.go new file mode 100644 index 00000000..7abf815d --- /dev/null +++ b/internal/ui/imagepreview/imagepreview.go @@ -0,0 +1,233 @@ +// Package imagepreview renders low-resolution, in-terminal thumbnails of +// images using Unicode upper half-block characters (U+2580, "▀") combined +// with SGR foreground/background color codes. +// +// The technique stacks two vertical pixels into a single character cell: the +// foreground color paints the top pixel and the background color paints the +// bottom pixel. This produces pure styled text — no graphics escape sequences +// — so the output survives terminal multiplexers (tmux, zellij) untouched. +// +// The Kitty graphics protocol, Sixel, and iTerm2 inline images are +// deliberately NOT used: those are graphics escape-sequence protocols that +// tmux and zellij strip or mangle by default. +package imagepreview + +import ( + "bytes" + "fmt" + "image" + "image/color" + "os" + "strings" + + // Register the standard image decoders so image.Decode can handle the + // common clipboard / attachment formats. + _ "image/gif" + _ "image/jpeg" + _ "image/png" + + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/ansi" + xdraw "golang.org/x/image/draw" +) + +// upperHalfBlock is U+2580 ("▀"). The glyph fills the top half of a cell, +// letting the foreground color render the top pixel and the cell's background +// color render the bottom pixel. +const upperHalfBlock = "▀" + +// reset is the SGR reset sequence appended after each rendered row. +const reset = "\x1b[0m" + +// maxImageDimension is the largest width or height, in pixels, that Render will +// fully decode. Images larger than this in either axis are rejected before the +// expensive image.Decode call to guard against decompression bombs (small +// encoded payloads that expand to enormous pixel buffers). +const maxImageDimension = 20000 + +// Render returns a half-block ANSI thumbnail of the image, scaled to fit +// within maxCols x maxRows terminal cells while preserving aspect ratio. +// +// Each terminal cell encodes two vertically-stacked pixels, so the effective +// pixel resolution of the thumbnail is up to maxCols x (maxRows*2). +// +// Colors are emitted at the fidelity of the detected terminal color profile: +// truecolor (24-bit) when available, degrading to 256-color. When the +// terminal supports neither (no truecolor and no 256-color), Render returns +// an empty string and a nil error so the caller can fall back to a text +// indicator. A non-nil error is only returned when the image data cannot be +// decoded. +// +// bg is the color used to composite transparent pixels (typically the +// terminal background). A nil bg defaults to black. +func Render(data []byte, mediaType string, maxCols, maxRows int, bg color.Color) (string, error) { + profile := colorprofile.Env(os.Environ()) + return renderWithProfile(data, maxCols, maxRows, bg, profile) +} + +// renderWithProfile is the testable core of Render. It accepts an explicit +// color profile instead of detecting one from the environment. +func renderWithProfile(data []byte, maxCols, maxRows int, bg color.Color, profile colorprofile.Profile) (string, error) { + // Half-block fidelity needs at least 256-color support. Anything less + // degrades to the caller's text fallback. + if profile < colorprofile.ANSI256 { + return "", nil + } + if maxCols < 1 || maxRows < 1 { + return "", nil + } + if bg == nil { + bg = color.Black + } + + // Guard against decompression bombs: inspect the header dimensions before + // fully decoding, so a small malicious payload cannot expand into an + // enormous pixel buffer. + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("decode image config: %w", err) + } + if cfg.Width > maxImageDimension || cfg.Height > maxImageDimension { + return "", fmt.Errorf("decode image: dimensions %dx%d exceed limit %d", cfg.Width, cfg.Height, maxImageDimension) + } + + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return "", fmt.Errorf("decode image: %w", err) + } + + // Target pixel dimensions: one pixel per column horizontally and two + // pixels per row vertically (the half-block trick). + cols, rows := fitDimensions(img.Bounds().Dx(), img.Bounds().Dy(), maxCols, maxRows) + if cols < 1 || rows < 1 { + return "", nil + } + pxW, pxH := cols, rows*2 + + scaled := image.NewRGBA(image.Rect(0, 0, pxW, pxH)) + xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), img, img.Bounds(), xdraw.Over, nil) + + var b strings.Builder + for y := 0; y < pxH; y += 2 { + for x := range pxW { + top := composite(scaled.At(x, y), bg) + bottom := composite(scaled.At(x, y+1), bg) + b.WriteString(sgr(top, bottom, profile)) + b.WriteString(upperHalfBlock) + } + b.WriteString(reset) + if y+2 < pxH { + b.WriteByte('\n') + } + } + return b.String(), nil +} + +// fitDimensions returns the largest cell dimensions (cols, rows) that fit a +// srcW x srcH image inside a maxCols x maxRows box while preserving aspect +// ratio. Because each cell stacks two vertical pixels, a terminal cell is +// treated as roughly twice as tall as it is wide, which keeps the thumbnail's +// aspect ratio visually correct. +func fitDimensions(srcW, srcH, maxCols, maxRows int) (cols, rows int) { + if srcW <= 0 || srcH <= 0 { + return 0, 0 + } + // Work in pixel space: the box is maxCols wide and maxRows*2 tall. + maxPxW := float64(maxCols) + maxPxH := float64(maxRows * 2) + scale := maxPxW / float64(srcW) + if h := maxPxH / float64(srcH); h < scale { + scale = h + } + if scale > 1 { + scale = 1 // never upscale; keep the low-res look + } + pxW := int(float64(srcW) * scale) + pxH := int(float64(srcH) * scale) + if pxW < 1 { + pxW = 1 + } + if pxH < 2 { + pxH = 2 + } + // Convert back to cells; round the row count up to an even pixel height. + cols = pxW + rows = (pxH + 1) / 2 + if cols > maxCols { + cols = maxCols + } + if rows > maxRows { + rows = maxRows + } + return cols, rows +} + +// composite blends a (possibly translucent) pixel over the background color, +// returning an opaque color. Fully opaque pixels are returned unchanged. +func composite(c, bg color.Color) color.Color { + r, g, b, a := c.RGBA() + if a == 0xffff { + return c + } + br, bgc, bb, _ := bg.RGBA() + // Standard "over" alpha compositing in 16-bit space. + inv := 0xffff - a + out := color.RGBA64{ + R: uint16(r + br*inv/0xffff), + G: uint16(g + bgc*inv/0xffff), + B: uint16(b + bb*inv/0xffff), + A: 0xffff, + } + return out +} + +// sgr builds the SGR escape sequence that sets the foreground (top pixel) and +// background (bottom pixel) colors at the fidelity of the given profile. +func sgr(fg, bg color.Color, profile colorprofile.Profile) string { + if profile >= colorprofile.TrueColor { + fr, fgc, fb := rgb8(fg) + br, bgc, bb := rgb8(bg) + return fmt.Sprintf("\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm", fr, fgc, fb, br, bgc, bb) + } + return fmt.Sprintf("\x1b[38;5;%d;48;5;%dm", index256(fg, profile), index256(bg, profile)) +} + +// rgb8 reduces a color to 8-bit RGB components. +func rgb8(c color.Color) (r, g, b uint8) { + cr, cg, cb, _ := c.RGBA() + return uint8(cr >> 8), uint8(cg >> 8), uint8(cb >> 8) +} + +// index256 converts a color to its nearest 256-color palette index using the +// supplied profile. +func index256(c color.Color, profile colorprofile.Profile) uint8 { + cc := profile.Convert(c) + if idx, ok := cc.(ansi.IndexedColor); ok { + return uint8(idx) + } + if idx, ok := cc.(ansi.BasicColor); ok { + return uint8(idx) + } + // Fallback: derive an index directly if conversion produced an + // unexpected type. + r, g, b := rgb8(c) + return ansi256FromRGB(r, g, b) +} + +// ansi256FromRGB maps an 8-bit RGB color to the xterm 256-color cube. It is a +// best-effort fallback used only when profile.Convert does not yield a known +// indexed color type. +func ansi256FromRGB(r, g, b uint8) uint8 { + q := func(v uint8) int { + switch { + case v < 48: + return 0 + case v < 115: + return 1 + default: + return int((v - 35) / 40) + } + } + ri, gi, bi := q(r), q(g), q(b) + return uint8(16 + 36*ri + 6*gi + bi) +} diff --git a/internal/ui/imagepreview/imagepreview_test.go b/internal/ui/imagepreview/imagepreview_test.go new file mode 100644 index 00000000..f70ef137 --- /dev/null +++ b/internal/ui/imagepreview/imagepreview_test.go @@ -0,0 +1,193 @@ +package imagepreview + +import ( + "bytes" + "image" + "image/color" + "image/png" + "strings" + "testing" + + "github.com/charmbracelet/colorprofile" +) + +// makePNG builds a simple w x h PNG filled with the given color and returns +// its encoded bytes. +func makePNG(t *testing.T, w, h int, c color.Color) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +func TestRenderTrueColor(t *testing.T) { + data := makePNG(t, 20, 20, color.RGBA{R: 255, A: 255}) + out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == "" { + t.Fatal("expected non-empty thumbnail for truecolor profile") + } + if !strings.Contains(out, upperHalfBlock) { + t.Error("output should contain upper half block glyphs") + } + if !strings.Contains(out, "\x1b[38;2;") || !strings.Contains(out, "48;2;") { + t.Errorf("expected truecolor SGR sequences, got %q", out) + } + // Red fill should appear as 255;0;0 somewhere. + if !strings.Contains(out, "255;0;0") { + t.Errorf("expected red color in output, got %q", out) + } +} + +func TestRenderANSI256(t *testing.T) { + data := makePNG(t, 20, 20, color.RGBA{G: 255, A: 255}) + out, err := renderWithProfile(data, 8, 4, color.Black, colorprofile.ANSI256) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == "" { + t.Fatal("expected non-empty thumbnail for ANSI256 profile") + } + if !strings.Contains(out, "\x1b[38;5;") || !strings.Contains(out, "48;5;") { + t.Errorf("expected 256-color SGR sequences, got %q", out) + } + if strings.Contains(out, "38;2;") { + t.Errorf("ANSI256 output should not contain truecolor sequences, got %q", out) + } +} + +func TestRenderDegradesBelowANSI256(t *testing.T) { + data := makePNG(t, 20, 20, color.RGBA{B: 255, A: 255}) + for _, p := range []colorprofile.Profile{colorprofile.ANSI, colorprofile.ASCII, colorprofile.NoTTY} { + out, err := renderWithProfile(data, 10, 5, color.Black, p) + if err != nil { + t.Fatalf("profile %v: unexpected error: %v", p, err) + } + if out != "" { + t.Errorf("profile %v: expected empty fallback, got %q", p, out) + } + } +} + +func TestRenderInvalidImage(t *testing.T) { + out, err := renderWithProfile([]byte("not an image"), 10, 5, color.Black, colorprofile.TrueColor) + if err == nil { + t.Fatal("expected error for invalid image data") + } + if out != "" { + t.Errorf("expected empty output on decode error, got %q", out) + } +} + +func TestRenderRejectsOversizedImage(t *testing.T) { + // A header advertising dimensions beyond maxImageDimension must be + // rejected before full decode (decompression-bomb guard). image.RGBA + // allocation is avoided by only checking the config path here. + w := maxImageDimension + 1 + data := makePNG(t, w, 1, color.White) + out, err := renderWithProfile(data, 10, 5, color.Black, colorprofile.TrueColor) + if err == nil { + t.Fatal("expected error for oversized image dimensions") + } + if out != "" { + t.Errorf("expected empty output for oversized image, got %q", out) + } +} + +func TestRenderZeroBox(t *testing.T) { + data := makePNG(t, 20, 20, color.White) + out, err := renderWithProfile(data, 0, 0, color.Black, colorprofile.TrueColor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "" { + t.Errorf("expected empty output for zero-sized box, got %q", out) + } +} + +func TestRenderNilBackgroundDefaults(t *testing.T) { + data := makePNG(t, 10, 10, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + out, err := renderWithProfile(data, 6, 3, nil, colorprofile.TrueColor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out == "" { + t.Fatal("expected output with nil background (defaults to black)") + } +} + +func TestRowCountWithinBounds(t *testing.T) { + // A tall image should be capped at maxRows cells. + data := makePNG(t, 10, 100, color.White) + out, err := renderWithProfile(data, 20, 6, color.Black, colorprofile.TrueColor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + rows := strings.Count(out, "\n") + 1 + if rows > 6 { + t.Errorf("expected at most 6 rows, got %d", rows) + } +} + +func TestColumnCountWithinBounds(t *testing.T) { + // A wide image should be capped at maxCols cells per row. + data := makePNG(t, 100, 10, color.White) + out, err := renderWithProfile(data, 8, 20, color.Black, colorprofile.TrueColor) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + firstRow := strings.SplitN(out, "\n", 2)[0] + cols := strings.Count(firstRow, upperHalfBlock) + if cols > 8 { + t.Errorf("expected at most 8 columns, got %d", cols) + } + if cols == 0 { + t.Error("expected at least one column") + } +} + +func TestFitDimensionsPreservesAspect(t *testing.T) { + // 2:1 (wide) image into a 40x20 box. Pixel box is 40x40; width-bound. + cols, rows := fitDimensions(200, 100, 40, 20) + if cols != 40 { + t.Errorf("expected 40 cols, got %d", cols) + } + // pxH = 100 * (40/200) = 20 → 10 rows. + if rows != 10 { + t.Errorf("expected 10 rows, got %d", rows) + } +} + +func TestFitDimensionsNeverUpscales(t *testing.T) { + cols, rows := fitDimensions(4, 4, 40, 20) + if cols != 4 || rows != 2 { + t.Errorf("expected 4x2 (no upscale), got %dx%d", cols, rows) + } +} + +func TestCompositeOpaquePassthrough(t *testing.T) { + c := color.RGBA{R: 1, G: 2, B: 3, A: 255} + got := composite(c, color.White) + if got != color.Color(c) { + t.Errorf("opaque color should pass through unchanged, got %v", got) + } +} + +func TestCompositeTransparentOverBackground(t *testing.T) { + // Fully transparent pixel over red background should yield red. + got := composite(color.RGBA{}, color.RGBA{R: 255, A: 255}) + r, g, b, a := got.RGBA() + if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 || a != 0xffff { + t.Errorf("expected opaque red, got r=%d g=%d b=%d a=%d", r>>8, g>>8, b>>8, a) + } +} diff --git a/internal/ui/input.go b/internal/ui/input.go index 401ff77d..38ab4b5c 100644 --- a/internal/ui/input.go +++ b/internal/ui/input.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "image/color" "sort" "strings" @@ -13,6 +14,7 @@ import ( "github.com/mark3labs/kit/internal/clipboard" "github.com/mark3labs/kit/internal/ui/commands" "github.com/mark3labs/kit/internal/ui/core" + "github.com/mark3labs/kit/internal/ui/imagepreview" "github.com/mark3labs/kit/internal/ui/style" ) @@ -80,6 +82,23 @@ type InputComponent struct { // Images are added via Ctrl+V and cleared on submit or Ctrl+U. pendingImages []core.ImageAttachment + // imageThumbs caches the rendered half-block thumbnail for each entry in + // pendingImages (1:1 index correspondence). Thumbnails are rendered + // asynchronously off the Bubble Tea event loop (decode + resample is too + // slow to run inside Update), so an entry starts as the empty string + // placeholder and is filled in when the matching thumbnailReadyMsg + // arrives. An entry stays empty when the terminal cannot display a + // half-block preview, in which case the text pill is shown alone. + // See internal/ui/imagepreview. + imageThumbs []string + + // imageGen is a monotonic generation counter incremented whenever the + // pending image set is cleared. Async thumbnail results carry the + // generation they were enqueued under and are discarded if it no longer + // matches, preventing a stale thumbnail from landing on the wrong slot + // after a clear + re-attach. + imageGen int + // history stores previously submitted prompts (most recent last). // Limited to maxHistory entries; duplicates of the previous entry are // skipped. Empty strings are never stored. @@ -105,6 +124,16 @@ type clipboardImageMsg struct { err error } +// thumbnailReadyMsg carries the result of an async thumbnail render back to +// the Update loop. gen and index identify the pendingImages slot the +// thumbnail belongs to; the result is dropped if the generation no longer +// matches (the pending set was cleared) or the index is out of range. +type thumbnailReadyMsg struct { + gen int + index int + thumb string +} + // NewInputComponent creates a new InputComponent with the given width and // optional AppController. If appCtrl is nil the component still works but // /clear and /clear-queue are no-ops. @@ -193,7 +222,23 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, nil } if msg.image != nil { - s.pendingImages = append(s.pendingImages, *msg.image) + img := *msg.image + index := len(s.pendingImages) + s.pendingImages = append(s.pendingImages, img) + // Reserve a placeholder; the async render fills it in via + // thumbnailReadyMsg so Update never blocks on decode/resample. + s.imageThumbs = append(s.imageThumbs, "") + cols := s.thumbCols() + if cols < 1 { + return s, nil + } + return s, renderThumbnailCmd(img, cols, thumbMaxRows, style.GetTheme().Background, s.imageGen, index) + } + return s, nil + + case thumbnailReadyMsg: + if msg.gen == s.imageGen && msg.index >= 0 && msg.index < len(s.imageThumbs) { + s.imageThumbs[msg.index] = msg.thumb } return s, nil @@ -250,6 +295,8 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Clear all pending image attachments. if len(s.pendingImages) > 0 { s.pendingImages = nil + s.imageThumbs = nil + s.imageGen++ return s, nil } } @@ -486,6 +533,8 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd { // images and clear them. images := s.pendingImages s.pendingImages = nil + s.imageThumbs = nil + s.imageGen++ return func() tea.Msg { return core.SubmitMsg{Text: trimmed, Images: images} } @@ -519,6 +568,42 @@ func (s *InputComponent) resetHistoryBrowsing() { s.savedInput = "" } +// thumbMaxCols and thumbMaxRows cap the size, in terminal cells, of pending +// image previews. Kept small for the low-res look and to keep scrollback +// light. +const ( + thumbMaxCols = 40 + thumbMaxRows = 12 +) + +// thumbCols returns the thumbnail width in terminal cells given the current +// input width, or 0 when there is no room to render a preview. +func (s *InputComponent) thumbCols() int { + if s.width <= 6 { + return 0 + } + cols := min(thumbMaxCols, s.width-6) + if cols < 1 { + return 0 + } + return cols +} + +// renderThumbnailCmd returns a tea.Cmd that renders a half-block ANSI preview +// off the Bubble Tea event loop. The decode + resample work runs in the Cmd +// goroutine, and the result is delivered as a thumbnailReadyMsg tagged with +// the generation and slot index it was enqueued for. An empty thumbnail +// (terminal unsupported or render error) leaves the text pill in place. +func renderThumbnailCmd(img core.ImageAttachment, cols, rows int, bg color.Color, gen, index int) tea.Cmd { + return func() tea.Msg { + thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, rows, bg) + if err != nil { + thumb = "" + } + return thumbnailReadyMsg{gen: gen, index: index, thumb: thumb} + } +} + // View implements tea.Model. Renders the textarea, autocomplete popup // (if visible), and help text. func (s *InputComponent) View() tea.View { @@ -544,7 +629,9 @@ func (s *InputComponent) View() tea.View { // Popup is now rendered as a centered overlay in AppModel.View() // instead of inline here to prevent bottom overflow - // Show image attachment indicator when images are pending. + // Show image attachment previews when images are pending. A cached + // half-block thumbnail is rendered when the terminal supports it; + // otherwise the text pill alone is shown. if len(s.pendingImages) > 0 { imgStyle := lipgloss.NewStyle(). Foreground(theme.Secondary). @@ -553,6 +640,14 @@ func (s *InputComponent) View() tea.View { label := fmt.Sprintf("[%d image(s) attached] ctrl+u to clear", len(s.pendingImages)) view.WriteString("\n") view.WriteString(imgStyle.Render(label)) + + thumbStyle := lipgloss.NewStyle().PaddingLeft(3) + for i := range s.pendingImages { + if i < len(s.imageThumbs) && s.imageThumbs[i] != "" { + view.WriteString("\n") + view.WriteString(thumbStyle.Render(s.imageThumbs[i])) + } + } } if !s.hideHint { @@ -844,6 +939,8 @@ func readClipboardImageCmd() tea.Cmd { func (s *InputComponent) ClearPendingImages() []core.ImageAttachment { images := s.pendingImages s.pendingImages = nil + s.imageThumbs = nil + s.imageGen++ return images } diff --git a/www/pages/cli/commands.md b/www/pages/cli/commands.md index e49fe467..6cf17eb7 100644 --- a/www/pages/cli/commands.md +++ b/www/pages/cli/commands.md @@ -110,6 +110,23 @@ Press **Ctrl+X s** during streaming to inject a system-level instruction mid-tur Example: While the model is writing code, press Ctrl+X s and type "Use async/await instead" to change the implementation approach. +### Image attachments + +Attach images to your next prompt straight from the clipboard: + +- Copy an image (e.g. a screenshot) to the system clipboard, then press **Ctrl+V** in the input to attach it. +- Press **Ctrl+U** to clear all pending image attachments. +- Attachments are sent alongside your text when you submit, and cleared afterward. + +When a terminal supports color, Kit renders a small low-resolution **thumbnail preview** of each pending image directly in the input, below the `[N image(s) attached]` indicator, so you can confirm the right image was attached before sending. + +The preview is drawn with Unicode half-block characters and ordinary terminal colors — not a graphics protocol — so it renders correctly inside terminal multiplexers like **tmux** and **zellij**. Thumbnails are capped to a small cell box for a glanceable, low-res look. + +- Best fidelity needs a **truecolor** terminal (`COLORTERM=truecolor`); Kit degrades to 256-color where truecolor is unavailable. +- On terminals with neither, the preview is skipped and the `[N image(s) attached]` text indicator is shown alone. + +You can also attach image files by referencing them with `@path/to/image.png` — binary files are auto-detected by MIME type. See [Quick Start](/quick-start) for the `@` attachment syntax. + ## Prompt templates ### Creating templates