mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d27eccc615 | |||
| f6a48f4a39 | |||
| 8c7b007c78 |
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+99
-2
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user