Commit Diff


commit - 24a58b81d73f151c7ba7236890a6e13aea9f0c78
commit + eca55a61963c6f7a60742d25a78010e6ca72386e
blob - cc609bb9759125ce12bd242c0c2bd2cfd1af771e
blob + 2acdd6a7f7c59eac015c4ac41b9f84b6787f8b57
--- go.mod
+++ go.mod
@@ -10,6 +10,8 @@ require (
 	github.com/atotto/clipboard v0.1.4
 	github.com/charmbracelet/glamour v1.0.0
 	github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
+	github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
+	github.com/charmbracelet/x/ansi v0.11.7
 	github.com/muesli/coral v1.0.0
 	github.com/muesli/termenv v0.16.0
 	github.com/stretchr/testify v1.11.1
@@ -25,8 +27,6 @@ require (
 	github.com/charmbracelet/colorprofile v0.4.3 // indirect
 	github.com/charmbracelet/keygen v0.5.4 // indirect
 	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
-	github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
-	github.com/charmbracelet/x/ansi v0.11.7 // indirect
 	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
 	github.com/charmbracelet/x/conpty v0.1.1 // indirect
 	github.com/charmbracelet/x/errors v0.0.0-20251110184232-6ab307057ac7 // indirect
blob - 281bfaa5f83ec7b9dc3112c1361f402f61293a8a
blob + ed1ab6707ef089eb569896d23faf307bf298165b
--- go.sum
+++ go.sum
@@ -8,6 +8,10 @@ charm.land/log/v2 v2.0.0 h1:SY3Cey7ipx86/MBXQHwsguOT6X
 charm.land/log/v2 v2.0.0/go.mod h1:c3cZSRqm20qUVVAR1WmS/7ab8bgha3C6G7DjPcaVZz0=
 charm.land/wish/v2 v2.0.1 h1:xYOsvQG/bYNRoKUBt0AEyMiCv9rksmCOG/fD5WifAzg=
 charm.land/wish/v2 v2.0.1/go.mod h1:uBWdsKTFk9BE0JsdEv8KKF8/abBfRe/hKEGOBm3OKZA=
+dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
 github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
 github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
 github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
@@ -24,10 +28,12 @@ github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeD
 github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
+github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
 github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
 github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
 github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
 github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
+github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/keygen v0.5.4 h1:XQYgf6UEaTGgQSSmiPpIQ78WfseNQp4Pz8N/c1OsrdA=
 github.com/charmbracelet/keygen v0.5.4/go.mod h1:t4oBRr41bvK7FaJsAaAQhhkUuHslzFXVjOBwA55CZNM=
 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
@@ -56,27 +62,40 @@ github.com/charmbracelet/x/windows v0.2.2 h1:Iofanmuva
 github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
 github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
 github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
 github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
 github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
 github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
 github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
+github.com/go-git/go-git/v5 v5.17.2/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
 github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE=
 github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk=
+github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
 github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@@ -92,6 +111,7 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+6
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
+github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
@@ -99,10 +119,15 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6Ztn
 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sahilm/fuzzy v0.1.3/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8=
+github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
@@ -113,6 +138,7 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH
 golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
 golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
 golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
 golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
@@ -124,8 +150,13 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIV
 golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
 golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
 golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
blob - /dev/null
blob + 5e48cfac7a6d71ae8cbc008d80be16e8c108740a (mode 644)
--- /dev/null
+++ internal/image/cache.go
@@ -0,0 +1,125 @@
+package image
+
+import (
+	stdimage "image"
+	_ "image/gif"
+	_ "image/jpeg"
+	_ "image/png"
+	"os"
+	"sync"
+)
+
+// cellWidthOverHeight approximates a terminal cell's pixel aspect ratio
+// (cells are roughly twice as tall as wide in most monospace fonts). There
+// is no cheap way to query the real value without an extra async
+// round-trip to the terminal, so v1 uses this fixed assumption.
+const cellWidthOverHeight = 0.5
+
+// defaultMaxCols caps how wide a single image gets by default, so one
+// picture doesn't consume the whole slide width on a very wide terminal.
+const defaultMaxCols = 60
+
+// maxRows caps how tall a single image gets, so a narrow/tall image
+// doesn't consume the whole slide height.
+const maxRows = 20
+
+// Entry is a resolved, ready-to-place image.
+type Entry struct {
+	ID   int
+	Cols int
+	Rows int
+}
+
+type cacheKey struct {
+	path    string
+	maxCols int
+}
+
+// Cache decodes and transmits images over the Kitty Graphics Protocol,
+// keyed by resolved path and target width, and assigns each a stable ID
+// for the process's lifetime.
+type Cache struct {
+	mu      sync.RWMutex
+	entries map[cacheKey]Entry
+	nextID  int
+}
+
+func NewCache() *Cache {
+	return &Cache{entries: make(map[cacheKey]Entry), nextID: 1}
+}
+
+// Get returns the entry for path/maxCols if it has already been loaded.
+// It does no I/O and is safe to call from View() on every frame.
+func (c *Cache) Get(path string, maxCols int) (Entry, bool) {
+	c.mu.RLock()
+	defer c.mu.RUnlock()
+	e, ok := c.entries[cacheKey{path, maxCols}]
+	return e, ok
+}
+
+// Load decodes and encodes the image at path if it isn't cached yet,
+// returning the entry and the Kitty transmit sequence to send for it. It
+// does file I/O and image decoding, so callers must only invoke it from a
+// tea.Cmd, never from View(). If the entry is already cached, transmitSeq
+// is empty and there is nothing new to send.
+func (c *Cache) Load(path string, maxCols int) (entry Entry, transmitSeq string, err error) {
+	if e, ok := c.Get(path, maxCols); ok {
+		return e, "", nil
+	}
+
+	f, err := os.Open(path)
+	if err != nil {
+		return Entry{}, "", err
+	}
+	defer f.Close()
+
+	img, _, err := stdimage.Decode(f)
+	if err != nil {
+		return Entry{}, "", err
+	}
+
+	cols, rows := fitSize(img.Bounds(), maxCols)
+
+	c.mu.Lock()
+	id := c.nextID
+	c.nextID++
+	e := Entry{ID: id, Cols: cols, Rows: rows}
+	c.entries[cacheKey{path, maxCols}] = e
+	c.mu.Unlock()
+
+	seq, err := TransmitSeq(img, id, cols, rows)
+	if err != nil {
+		return Entry{}, "", err
+	}
+	return e, seq, nil
+}
+
+// fitSize picks a cols x rows grid that preserves the image's aspect
+// ratio within the column/row caps.
+func fitSize(bounds stdimage.Rectangle, maxCols int) (cols, rows int) {
+	w, h := bounds.Dx(), bounds.Dy()
+	if w <= 0 || h <= 0 {
+		return 1, 1
+	}
+
+	cols = maxCols
+	if cols > defaultMaxCols {
+		cols = defaultMaxCols
+	}
+	if cols < 1 {
+		cols = 1
+	}
+
+	rows = int(float64(cols) * cellWidthOverHeight * float64(h) / float64(w))
+	if rows > maxRows {
+		rows = maxRows
+		cols = int(float64(rows) / cellWidthOverHeight * float64(w) / float64(h))
+	}
+	if rows < 1 {
+		rows = 1
+	}
+	if cols < 1 {
+		cols = 1
+	}
+	return cols, rows
+}
blob - /dev/null
blob + 7ad42469e8a9b3ce56171b30260b7cea266393bc (mode 644)
--- /dev/null
+++ internal/image/cache_test.go
@@ -0,0 +1,86 @@
+package image_test
+
+import (
+	stdimage "image"
+	"image/color"
+	"image/png"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/maaslalani/slides/internal/image"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func writeTestPNG(t *testing.T, dir, name string, w, h int) string {
+	t.Helper()
+	img := stdimage.NewRGBA(stdimage.Rect(0, 0, w, h))
+	for y := 0; y < h; y++ {
+		for x := 0; x < w; x++ {
+			img.Set(x, y, color.RGBA{R: 200, G: 100, B: 50, A: 255})
+		}
+	}
+	path := filepath.Join(dir, name)
+	f, err := os.Create(path)
+	require.NoError(t, err)
+	defer f.Close()
+	require.NoError(t, png.Encode(f, img))
+	return path
+}
+
+func TestCache_LoadAndGet(t *testing.T) {
+	dir := t.TempDir()
+	path := writeTestPNG(t, dir, "wide.png", 200, 100)
+
+	c := image.NewCache()
+
+	_, ok := c.Get(path, 40)
+	assert.False(t, ok, "not loaded yet")
+
+	entry, seq, err := c.Load(path, 40)
+	require.NoError(t, err)
+	assert.NotEmpty(t, seq)
+	assert.Equal(t, 1, entry.ID)
+	assert.Equal(t, 40, entry.Cols)
+
+	got, ok := c.Get(path, 40)
+	assert.True(t, ok)
+	assert.Equal(t, entry, got)
+
+	entry2, seq2, err := c.Load(path, 40)
+	require.NoError(t, err)
+	assert.Equal(t, entry, entry2)
+	assert.Empty(t, seq2, "reloading a cached entry should not retransmit")
+}
+
+func TestCache_DifferentMaxColsGetsDifferentID(t *testing.T) {
+	dir := t.TempDir()
+	path := writeTestPNG(t, dir, "img.png", 100, 100)
+
+	c := image.NewCache()
+	e1, _, err := c.Load(path, 40)
+	require.NoError(t, err)
+	e2, _, err := c.Load(path, 20)
+	require.NoError(t, err)
+
+	assert.NotEqual(t, e1.ID, e2.ID)
+}
+
+func TestCache_TallImageCapsRows(t *testing.T) {
+	dir := t.TempDir()
+	path := writeTestPNG(t, dir, "tall.png", 10, 1000)
+
+	c := image.NewCache()
+	entry, _, err := c.Load(path, 60)
+	require.NoError(t, err)
+
+	assert.LessOrEqual(t, entry.Rows, 20)
+	assert.Greater(t, entry.Cols, 0)
+}
+
+func TestCache_MissingFile(t *testing.T) {
+	c := image.NewCache()
+	_, _, err := c.Load("/no/such/file.png", 40)
+	assert.Error(t, err)
+}
blob - /dev/null
blob + 8618c9015f2c4bb7d7fc89fe1d28268b1b17c420 (mode 644)
--- /dev/null
+++ internal/image/image.go
@@ -0,0 +1,97 @@
+// Package image renders markdown images (![alt](src "caption")) as real
+// terminal graphics via the Kitty Unicode Placeholder technique, with the
+// caption shown below. Sentinel lines used during pre-processing are
+// wrapped in Private Use Area characters so they can't collide with real
+// slide content and glamour's word-wrap never splits them (no spaces).
+package image
+
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+// Ref is a single ![alt](src "caption") reference found in a slide.
+type Ref struct {
+	Raw     string
+	Alt     string
+	Src     string
+	Caption string
+}
+
+// Placement pairs a resolved Ref with its cache Entry, in sentinel order.
+type Placement struct {
+	Ref   Ref
+	Entry Entry
+}
+
+var refRe = regexp.MustCompile(`!\[([^\]]*)\]\(\s*(\S+?)(?:\s+"([^"]*)")?\s*\)`)
+
+// Parse extracts image references from raw slide markdown, in the order
+// they appear.
+func Parse(markdown string) []Ref {
+	var refs []Ref
+	for _, m := range refRe.FindAllStringSubmatch(markdown, -1) {
+		refs = append(refs, Ref{Raw: m[0], Alt: m[1], Src: m[2], Caption: m[3]})
+	}
+	return refs
+}
+
+const sentinelOpen = "IMG"
+const sentinelClose = ""
+
+var sentinelRe = regexp.MustCompile(sentinelOpen + `(\d+)` + sentinelClose)
+
+func sentinel(index int) string {
+	return fmt.Sprintf("%s%d%s", sentinelOpen, index, sentinelClose)
+}
+
+// Preprocess replaces each image reference whose cache entry is already
+// resolved (lookup returns ok) with a sentinel line, plus a caption
+// paragraph if present. References not yet resolved are left untouched, so
+// glamour falls back to its default "Image: alt -> src" rendering until
+// they become ready. The returned placements are indexed exactly as the
+// sentinels appear, for Expand to consume after markdown rendering.
+func Preprocess(markdown string, lookup func(Ref) (Entry, bool)) (string, []Placement) {
+	var placements []Placement
+	for _, ref := range Parse(markdown) {
+		entry, ok := lookup(ref)
+		if !ok {
+			continue
+		}
+		replacement := sentinel(len(placements))
+		if ref.Caption != "" {
+			replacement += "\n\n*" + ref.Caption + "*"
+		}
+		markdown = strings.Replace(markdown, ref.Raw, replacement, 1)
+		placements = append(placements, Placement{Ref: ref, Entry: entry})
+	}
+	return markdown, placements
+}
+
+// Expand scans already-rendered text (post markdown, post padding) line by
+// line and replaces each sentinel line with the real placeholder block for
+// its placement, expanding it to as many rows as the image needs.
+func Expand(rendered string, placements []Placement) string {
+	if len(placements) == 0 {
+		return rendered
+	}
+	lines := strings.Split(rendered, "\n")
+	out := make([]string, 0, len(lines))
+	for _, line := range lines {
+		m := sentinelRe.FindStringSubmatch(line)
+		if m == nil {
+			out = append(out, line)
+			continue
+		}
+		idx, err := strconv.Atoi(m[1])
+		if err != nil || idx < 0 || idx >= len(placements) {
+			out = append(out, line)
+			continue
+		}
+		e := placements[idx].Entry
+		out = append(out, strings.Split(PlaceholderBlock(e.ID, e.Cols, e.Rows), "\n")...)
+	}
+	return strings.Join(out, "\n")
+}
blob - /dev/null
blob + 04997a8b08fe05f30a6ed2cfe260b976c5e48dcc (mode 644)
--- /dev/null
+++ internal/image/image_test.go
@@ -0,0 +1,93 @@
+package image_test
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi/kitty"
+	"github.com/maaslalani/slides/internal/image"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestParse(t *testing.T) {
+	tests := []struct {
+		name     string
+		markdown string
+		want     []image.Ref
+	}{
+		{
+			name:     "no images",
+			markdown: "# Just a title\n\nSome text.",
+			want:     nil,
+		},
+		{
+			name:     "image without caption",
+			markdown: "![a diagram](./diagram.png)",
+			want: []image.Ref{
+				{Raw: "![a diagram](./diagram.png)", Alt: "a diagram", Src: "./diagram.png"},
+			},
+		},
+		{
+			name:     "image with caption",
+			markdown: `![a diagram](./diagram.png "Figure 1: the diagram")`,
+			want: []image.Ref{
+				{
+					Raw:     `![a diagram](./diagram.png "Figure 1: the diagram")`,
+					Alt:     "a diagram",
+					Src:     "./diagram.png",
+					Caption: "Figure 1: the diagram",
+				},
+			},
+		},
+		{
+			name:     "two images on the same slide",
+			markdown: "![a](a.png)\n\n![b](b.png)",
+			want: []image.Ref{
+				{Raw: "![a](a.png)", Alt: "a", Src: "a.png"},
+				{Raw: "![b](b.png)", Alt: "b", Src: "b.png"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			assert.Equal(t, tt.want, image.Parse(tt.markdown))
+		})
+	}
+}
+
+func TestPreprocessAndExpand(t *testing.T) {
+	md := "# Title\n\n![a chart](chart.png \"Figure 1\")\n\nSome text after."
+
+	lookup := func(ref image.Ref) (image.Entry, bool) {
+		if ref.Src == "chart.png" {
+			return image.Entry{ID: 5, Cols: 10, Rows: 3}, true
+		}
+		return image.Entry{}, false
+	}
+
+	pre, placements := image.Preprocess(md, lookup)
+	require.Len(t, placements, 1)
+	assert.Equal(t, image.Entry{ID: 5, Cols: 10, Rows: 3}, placements[0].Entry)
+	assert.NotContains(t, pre, "![a chart]")
+	assert.Contains(t, pre, "Figure 1")
+
+	expanded := image.Expand(pre, placements)
+	placeholderLines := 0
+	for _, l := range strings.Split(expanded, "\n") {
+		if strings.Contains(l, string(kitty.Placeholder)) {
+			placeholderLines++
+		}
+	}
+	assert.Equal(t, 3, placeholderLines)
+}
+
+func TestPreprocess_SkipsUnresolvedRefs(t *testing.T) {
+	md := "![missing](nope.png)"
+	pre, placements := image.Preprocess(md, func(image.Ref) (image.Entry, bool) {
+		return image.Entry{}, false
+	})
+	assert.Equal(t, md, pre)
+	assert.Empty(t, placements)
+}
blob - /dev/null
blob + 408a82584e7d1dac81abd7bee7bf905b2182a1cd (mode 644)
--- /dev/null
+++ internal/image/kitty.go
@@ -0,0 +1,87 @@
+package image
+
+import (
+	"bytes"
+	stdimage "image"
+	"strconv"
+	"strings"
+
+	"github.com/charmbracelet/x/ansi/kitty"
+)
+
+// ProbeID is a reserved image ID used only to check whether the terminal
+// understands the Kitty graphics protocol; never placed on screen.
+const ProbeID = 999999
+
+// ProbeSeq transmits a throwaway 1x1 image with quiet mode off, so the
+// terminal answers with an OK/error response that Update can watch for.
+func ProbeSeq() string {
+	img := stdimage.NewRGBA(stdimage.Rect(0, 0, 1, 1))
+	var buf bytes.Buffer
+	_ = kitty.EncodeGraphics(&buf, img, &kitty.Options{
+		ID:           ProbeID,
+		Action:       kitty.Transmit,
+		Transmission: kitty.Direct,
+		Format:       kitty.RGBA,
+		ImageWidth:   1,
+		ImageHeight:  1,
+		Quite:        0,
+	})
+	return buf.String()
+}
+
+// TransmitSeq encodes img as PNG (letting the terminal decode and scale it
+// itself, so we never need to resize pixels ourselves) and returns the
+// escape sequence that transmits it and registers a virtual placement
+// sized to cols x rows.
+func TransmitSeq(img stdimage.Image, id, cols, rows int) (string, error) {
+	var buf bytes.Buffer
+	err := kitty.EncodeGraphics(&buf, img, &kitty.Options{
+		ID:               id,
+		Action:           kitty.TransmitAndPut,
+		Transmission:     kitty.Direct,
+		Format:           kitty.PNG,
+		Columns:          cols,
+		Rows:             rows,
+		VirtualPlacement: true,
+		Chunk:            true,
+		Quite:            2,
+	})
+	return buf.String(), err
+}
+
+// fgSGR encodes id into a foreground color escape, replicating
+// ultraviolet's own reference encoding (id bytes -> indexed color when the
+// two high bytes are zero, else 24-bit truecolor).
+func fgSGR(id int) string {
+	r := (id >> 16) & 0xff
+	g := (id >> 8) & 0xff
+	b := id & 0xff
+	if r == 0 && g == 0 {
+		return "\x1b[38;5;" + strconv.Itoa(b) + "m"
+	}
+	return "\x1b[38;2;" + strconv.Itoa(r) + ";" + strconv.Itoa(g) + ";" + strconv.Itoa(b) + "m"
+}
+
+// PlaceholderBlock returns the Unicode-placeholder text for a placed
+// image: only the first cell of each row carries the row+column
+// diacritics, the terminal infers the rest from position -- validated
+// against Bubble Tea v2's cell-diffing renderer in cmd/spike-images.
+func PlaceholderBlock(id, cols, rows int) string {
+	fg := fgSGR(id)
+	const reset = "\x1b[0m"
+	lines := make([]string, rows)
+	for y := 0; y < rows; y++ {
+		var sb strings.Builder
+		sb.WriteString(fg)
+		sb.WriteRune(kitty.Placeholder)
+		sb.WriteRune(kitty.Diacritic(y))
+		sb.WriteRune(kitty.Diacritic(0))
+		for x := 1; x < cols; x++ {
+			sb.WriteRune(kitty.Placeholder)
+		}
+		sb.WriteString(reset)
+		lines[y] = sb.String()
+	}
+	return strings.Join(lines, "\n")
+}
blob - /dev/null
blob + f49f945cb7d01d45921c1d09ed9ad250e557d30e (mode 644)
--- /dev/null
+++ internal/image/kitty_test.go
@@ -0,0 +1,47 @@
+package image_test
+
+import (
+	stdimage "image"
+	"strings"
+	"testing"
+
+	"github.com/charmbracelet/x/ansi/kitty"
+	"github.com/maaslalani/slides/internal/image"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestPlaceholderBlock(t *testing.T) {
+	got := image.PlaceholderBlock(1, 2, 2)
+	lines := strings.Split(got, "\n")
+	require.Len(t, lines, 2)
+
+	const fg = "\x1b[38;5;1m"
+	const reset = "\x1b[0m"
+	ph := string(kitty.Placeholder)
+
+	assert.Equal(t, fg+ph+string(kitty.Diacritic(0))+string(kitty.Diacritic(0))+ph+reset, lines[0])
+	assert.Equal(t, fg+ph+string(kitty.Diacritic(1))+string(kitty.Diacritic(0))+ph+reset, lines[1])
+}
+
+func TestPlaceholderBlock_TruecolorID(t *testing.T) {
+	// A non-zero green byte forces the 24-bit branch instead of indexed color.
+	got := image.PlaceholderBlock(0x00010203, 1, 1)
+	assert.Contains(t, got, "\x1b[38;2;1;2;3m")
+}
+
+func TestProbeSeq(t *testing.T) {
+	seq := image.ProbeSeq()
+	assert.True(t, strings.HasPrefix(seq, "\x1b_G"))
+	assert.Contains(t, seq, "i=999999")
+}
+
+func TestTransmitSeq(t *testing.T) {
+	img := stdimage.NewRGBA(stdimage.Rect(0, 0, 4, 4))
+	seq, err := image.TransmitSeq(img, 7, 10, 5)
+	require.NoError(t, err)
+	assert.Contains(t, seq, "i=7")
+	assert.Contains(t, seq, "c=10")
+	assert.Contains(t, seq, "r=5")
+	assert.Contains(t, seq, "f=100")
+}
blob - 52ac1bcee142cba39202d2e856dec744f6ac96e5
blob + 5bebe5d79a354e68c5d4ae787959ea99cafe7a04
--- internal/model/model.go
+++ internal/model/model.go
@@ -7,6 +7,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"path/filepath"
 	"strings"
 	"time"
 
@@ -17,8 +18,11 @@ import (
 
 	"charm.land/bubbles/v2/viewport"
 	tea "charm.land/bubbletea/v2"
+	uv "github.com/charmbracelet/ultraviolet"
+
 	"github.com/charmbracelet/glamour"
 	"github.com/maaslalani/slides/internal/code"
+	"github.com/maaslalani/slides/internal/image"
 	"github.com/maaslalani/slides/internal/meta"
 	"github.com/maaslalani/slides/styles"
 )
@@ -49,20 +53,66 @@ type Model struct {
 	// original slides, it will be displayed on a slide and reset on page change
 	VirtualText string
 	Search      navigation.Search
+
+	images     *image.Cache
+	graphicsOK bool
 }
 
 type fileWatchMsg struct{}
 
+// imagesLoadedMsg carries the Kitty transmit sequences for images that
+// finished decoding since the last time it was requested.
+type imagesLoadedMsg struct{ seqs []string }
+
 var fileInfo os.FileInfo
 
-// Init initializes the model and begins watching the slides file for changes
-// if it exists.
+// Init initializes the model, begins watching the slides file for changes
+// if it exists, and probes whether the terminal supports the Kitty
+// graphics protocol.
 func (m Model) Init() tea.Cmd {
+	cmds := []tea.Cmd{tea.Raw(image.ProbeSeq())}
+	if m.FileName != "" {
+		fileInfo, _ = os.Stat(m.FileName)
+		cmds = append(cmds, fileWatchCmd())
+	}
+	return tea.Batch(cmds...)
+}
+
+// imageBaseDir returns the directory image sources are resolved relative
+// to: the slides file's directory, or the working directory for stdin.
+func (m *Model) imageBaseDir() string {
 	if m.FileName == "" {
+		return "."
+	}
+	return filepath.Dir(m.FileName)
+}
+
+// loadImagesCmd decodes and encodes any not-yet-cached images referenced
+// by slide, off the render path. It does file I/O, so it must only run
+// inside a tea.Cmd.
+func loadImagesCmd(images *image.Cache, baseDir, slide string, maxCols int) tea.Cmd {
+	refs := image.Parse(slide)
+	if images == nil || len(refs) == 0 {
 		return nil
 	}
-	fileInfo, _ = os.Stat(m.FileName)
-	return fileWatchCmd()
+	return func() tea.Msg {
+		var seqs []string
+		for _, ref := range refs {
+			path := filepath.Join(baseDir, ref.Src)
+			if _, ok := images.Get(path, maxCols); ok {
+				continue
+			}
+			_, seq, err := images.Load(path, maxCols)
+			if err != nil || seq == "" {
+				continue
+			}
+			seqs = append(seqs, seq)
+		}
+		if len(seqs) == 0 {
+			return nil
+		}
+		return imagesLoadedMsg{seqs: seqs}
+	}
 }
 
 func fileWatchCmd() tea.Cmd {
@@ -105,6 +155,9 @@ func (m *Model) Load() error {
 	if m.Theme == nil {
 		m.Theme = styles.SelectTheme(metaData.Theme)
 	}
+	if m.images == nil {
+		m.images = image.NewCache()
+	}
 
 	return nil
 }
@@ -115,7 +168,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 	case tea.WindowSizeMsg:
 		m.viewport.SetWidth(msg.Width)
 		m.viewport.SetHeight(msg.Height)
-		return m, nil
+		if !m.graphicsOK || len(m.Slides) == 0 {
+			return m, nil
+		}
+		return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
 
 	case tea.KeyPressMsg:
 		keyPress := msg.String()
@@ -185,8 +241,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd
 			}, keyPress)
 			m.buffer = newState.Buffer
 			m.SetPage(newState.Page)
+			if !m.graphicsOK {
+				return m, nil
+			}
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
 		}
 
+	case uv.KittyGraphicsEvent:
+		if msg.Options.ID == image.ProbeID && string(msg.Payload) == "OK" {
+			m.graphicsOK = true
+			return m, loadImagesCmd(m.images, m.imageBaseDir(), m.Slides[m.Page], m.viewport.Width())
+		}
+		return m, nil
+
+	case imagesLoadedMsg:
+		return m, tea.Raw(strings.Join(msg.seqs, ""))
+
 	case fileWatchMsg:
 		newFileInfo, err := os.Stat(m.FileName)
 		if err == nil && newFileInfo.ModTime() != fileInfo.ModTime() {
@@ -207,6 +277,15 @@ func (m Model) View() tea.View {
 	r, _ := glamour.NewTermRenderer(m.Theme, glamour.WithWordWrap(m.viewport.Width()))
 	slide := m.Slides[m.Page]
 	slide = code.HideComments(slide)
+
+	var placements []image.Placement
+	if m.graphicsOK && m.images != nil {
+		baseDir, maxCols := m.imageBaseDir(), m.viewport.Width()
+		slide, placements = image.Preprocess(slide, func(ref image.Ref) (image.Entry, bool) {
+			return m.images.Get(filepath.Join(baseDir, ref.Src), maxCols)
+		})
+	}
+
 	slide, err := r.Render(slide)
 	slide = strings.ReplaceAll(slide, "\t", tabSpaces)
 	slide += m.VirtualText
@@ -214,6 +293,9 @@ func (m Model) View() tea.View {
 		slide = fmt.Sprintf("Error: Could not render markdown! (%v)", err)
 	}
 	slide = styles.Slide.Render(slide)
+	if len(placements) > 0 {
+		slide = image.Expand(slide, placements)
+	}
 
 	var left string
 	if m.Search.Active {