feat(ui): render half-block thumbnails for attached images (#46)

- Add internal/ui/imagepreview package: Render() draws low-res
  thumbnails using Unicode half-blocks (▀) + truecolor/256-color SGR,
  which survives tmux/zellij (no graphics protocol)
- Cache a rendered thumbnail per pending clipboard image in the input
  component; render once at attach time, never per frame
- Fall back to the existing [N image(s) attached] text pill when the
  terminal lacks truecolor/256-color support
- Document Ctrl+V paste, Ctrl+U clear, and the preview in the docs
  site and README keyboard shortcuts

Fixes #46
This commit is contained in:
Ed Zynda
2026-06-04 14:02:01 +03:00
parent ae722d520f
commit 8c7b007c78
7 changed files with 467 additions and 2 deletions
+2
View File
@@ -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) |
+2 -1
View File
@@ -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
+2
View File
@@ -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=
+216
View File
@@ -0,0 +1,216 @@
// 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"
// 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
}
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)
}
@@ -0,0 +1,178 @@
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 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)
}
}
+50 -1
View File
@@ -13,6 +13,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 +81,13 @@ 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 once
// when an image is attached — never per frame — and an entry is the empty
// string when the terminal cannot display a half-block preview, in which
// case the text pill is shown instead. See internal/ui/imagepreview.
imageThumbs []string
// history stores previously submitted prompts (most recent last).
// Limited to maxHistory entries; duplicates of the previous entry are
// skipped. Empty strings are never stored.
@@ -194,6 +202,7 @@ func (s *InputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
if msg.image != nil {
s.pendingImages = append(s.pendingImages, *msg.image)
s.imageThumbs = append(s.imageThumbs, s.renderThumbnail(*msg.image))
}
return s, nil
@@ -250,6 +259,7 @@ 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
return s, nil
}
}
@@ -486,6 +496,7 @@ func (s *InputComponent) handleSubmit(value string) tea.Cmd {
// images and clear them.
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
return func() tea.Msg {
return core.SubmitMsg{Text: trimmed, Images: images}
}
@@ -519,6 +530,33 @@ 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
)
// renderThumbnail renders a half-block ANSI preview of an attached image once,
// at attach time, so View never re-renders it per frame. Returns an empty
// string when the terminal cannot display a preview (the caller then shows the
// text pill alone) or when rendering fails.
func (s *InputComponent) renderThumbnail(img core.ImageAttachment) string {
cols := thumbMaxCols
if s.width > 6 && s.width-6 < cols {
cols = s.width - 6
}
if cols < 1 {
return ""
}
thumb, err := imagepreview.Render(img.Data, img.MediaType, cols, thumbMaxRows, style.GetTheme().Background)
if err != nil {
return ""
}
return thumb
}
// View implements tea.Model. Renders the textarea, autocomplete popup
// (if visible), and help text.
func (s *InputComponent) View() tea.View {
@@ -544,7 +582,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 +593,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 +892,7 @@ func readClipboardImageCmd() tea.Cmd {
func (s *InputComponent) ClearPendingImages() []core.ImageAttachment {
images := s.pendingImages
s.pendingImages = nil
s.imageThumbs = nil
return images
}
+17
View File
@@ -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