Compare commits

...
Author SHA1 Message Date
anandh8x d195972df5 Keep Sixel companions in the reserved dock 2026-08-10 21:47:45 +05:30
Vasanthdev2004 cbb4a64ffd fix(tui): ask sixel terminals for their cell size and erase what was painted
The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.

petPixelProtocolSupported gates two unrelated questions that only looked like
one: whether pixel-precise dragging is available, and whether to ask the
terminal for its cell size with CSI 16 t. It answers true for Kitty only, so on
a sixel terminal the request was never sent, the reply never arrived, and
petCellPixelWidth/Height stayed zero for the whole session.

Everything downstream then took its fallback. petImageHeightPixels returned its
blind preferredHeight of 75 pixels instead of the cell-aware branch below it,
and the erase in clearRenderedImage used petImageColumns and petImageRows. Those
constants describe the RESERVED area, not what was painted. Windows Terminal
reports a 20x10 cell, so a 75-pixel sprite covers 4 rows and about 8 columns
while the erase blanked 5 by 9, taking a row and a column of interface with it
on every move. A drag repaints per pixel, so it did that continuously.

Windows Terminal answers CSI 16 t, confirmed by hand: it replies
ESC [6;20;10t. This was never a question of terminal support. Nothing asked.

So the request now goes out for every image protocol, via a predicate separate
from the dragging one, and the sixel footprint is computed from the rendered
pixel height and the reported cell size rather than from the constants. It is
still clamped to the reserved area, because the layout keeps that many cells
clear and the drag clamps to it, so a larger computed footprint would mean
erasing into live interface.

Deliberately NOT widening petPixelProtocolSupported itself: that would enable
pixel-precise dragging on sixel as a side effect, which is a separate feature
decision rather than part of this defect.

Kitty is untouched and a test pins that. There Columns and Rows are the
placement REQUEST handed to the terminal, which owns the region and scales into
it, so recomputing them would resize the image rather than fix an erase.

Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.
2026-08-10 20:26:06 +05:30
Vasanthdev2004 0c4a1f2cf6 fix(terminalpet): reset SGR before erasing a sixel companion
On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.

Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty hands
the terminal an image id and the placement goes away. A sixel is already in the
cell grid, so clearRenderedImage writes literal spaces over the cells it
occupied. Those spaces are ordinary characters and carry whatever SGR the TUI
last set, and nothing normalised it, so every erase stamped a filled block in
the ambient background. A drag erases and repaints on every pixel, so the block
was continuously refreshed over the UI.

It stayed invisible everywhere else because ghostty, kitty, WezTerm and iTerm
all resolve to the Kitty protocol and delete by id, never reaching this code.
Windows Terminal is the only mainstream terminal that lands on the sixel branch,
which is why testing on Linux and macOS could not have caught it.

The erase now resets SGR before painting, and uses DECSC/DECRC rather than
CSI s/u because only DECSC saves the attributes this clobbers. Restoring them
matters: the erase is emitted mid-frame, between styled writes the TUI has
already begun.

Measured before and after rather than reasoned about. Before:

  \x1b[s\x1b[3;41H      \x1b[4;41H      \x1b[5;41H      \x1b[u

no reset anywhere. After, the reset leads and DECRC closes.

Tests assert the bytes, since bytes are what the terminal sees: SGR is reset
BEFORE the first row is painted rather than merely present somewhere, the erase
still covers every claimed cell so the fix cannot be satisfied by erasing
nothing, and the Kitty path still deletes by id rather than acquiring the
character-painting erase. Verified by mutation: restoring the old sequence fails
the first of those with the exact reported symptom.

Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.
2026-08-10 20:09:14 +05:30
anandh8x c26a97da26 Handle sparse pet preference layouts 2026-08-10 17:56:30 +05:30
anandh8x 3c784e716a Preserve config layout when selecting pets 2026-08-10 17:42:41 +05:30
anandh8x 581f29b0ba Strengthen terminal companion regression tests 2026-08-10 16:39:34 +05:30
anandh8x d0fcb2d2b5 Fix terminal companion edge cases 2026-08-10 16:27:56 +05:30
anandh8x da2768dccd Polish interactive terminal companions 2026-08-10 15:48:20 +05:30
anandh8x d8e0267ea5 Add terminal companions 2026-08-10 15:47:41 +05:30
35 changed files with 6741 additions and 37 deletions

No files matched your search

+2 -1
View File
@@ -11,16 +11,17 @@ require (
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/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/term v0.2.2
github.com/coder/websocket v1.8.15
github.com/ledongthuc/pdf v0.0.0-20250511090121-5959a4027728
golang.org/x/image v0.44.0
golang.org/x/sys v0.47.0
mvdan.cc/sh/v3 v3.13.1
)
require (
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect
github.com/charmbracelet/x/exp/golden v0.0.0-20260615092313-b57e5e6d29bb // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
+2
View File
@@ -64,6 +64,8 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+1
View File
@@ -842,6 +842,7 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
Version: version,
Theme: theme,
SavedTheme: resolved.Preferences.Theme,
SavedPet: resolved.Preferences.Pet,
UserConfigPath: userConfigPath,
DoctorUserConfigPath: doctorUserConfigPath,
ProjectConfigPath: projectConfigPath,
+294
View File
@@ -0,0 +1,294 @@
package config
import (
"bytes"
"encoding/json"
"fmt"
)
type jsonObjectSpan struct {
start int
end int
members []jsonMemberSpan
}
type jsonMemberSpan struct {
key string
leadingStart int
keyStart int
valueStart int
valueEnd int
commaAfter int
}
// setPetPreferenceJSON changes only preferences.pet. It keeps the original
// bytes for every unrelated member so choosing a companion does not reorder or
// reformat the user's configuration file.
func setPetPreferenceJSON(data []byte, pet string) ([]byte, error) {
rootStart := skipJSONSpace(data, 0)
root, err := parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex := lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
if pet == "" {
return data, nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return insertJSONMember(data, root, "preferences", petJSONObject(encodedPet)), nil
}
preferenceMember := root.members[preferencesIndex]
preferenceValue := bytes.TrimSpace(data[preferenceMember.valueStart:preferenceMember.valueEnd])
if bytes.Equal(preferenceValue, []byte("null")) {
if pet == "" {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
return replaceJSONRange(data, preferenceMember.valueStart, preferenceMember.valueEnd, petJSONObject(encodedPet)), nil
}
preferences, err := parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, fmt.Errorf("preferences must be a JSON object: %w", err)
}
petIndex := lastJSONMember(preferences.members, "pet")
if pet != "" {
encodedPet, err := json.Marshal(pet)
if err != nil {
return nil, fmt.Errorf("encode pet preference: %w", err)
}
if petIndex >= 0 {
member := preferences.members[petIndex]
return replaceJSONRange(data, member.valueStart, member.valueEnd, encodedPet), nil
}
return insertJSONMember(data, preferences, "pet", encodedPet), nil
}
for petIndex >= 0 {
data = removeJSONMember(data, preferences, petIndex)
root, err = parseJSONObject(data, rootStart)
if err != nil {
return nil, err
}
preferencesIndex = lastJSONMember(root.members, "preferences")
if preferencesIndex < 0 {
return data, nil
}
preferenceMember = root.members[preferencesIndex]
preferences, err = parseJSONObject(data, preferenceMember.valueStart)
if err != nil {
return nil, err
}
petIndex = lastJSONMember(preferences.members, "pet")
}
if len(preferences.members) == 0 {
if lastJSONMember(root.members[:preferencesIndex], "preferences") >= 0 {
return data, nil
}
return removeJSONMember(data, root, preferencesIndex), nil
}
return data, nil
}
func parseJSONObject(data []byte, start int) (jsonObjectSpan, error) {
if start < 0 || start >= len(data) || data[start] != '{' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object")
}
object := jsonObjectSpan{start: start}
leadingStart := start + 1
for {
i := skipJSONSpace(data, leadingStart)
if i >= len(data) {
return jsonObjectSpan{}, fmt.Errorf("unterminated JSON object")
}
if data[i] == '}' {
object.end = i
return object, nil
}
if data[i] != '"' {
return jsonObjectSpan{}, fmt.Errorf("expected JSON object key")
}
keyStart := i
keyEnd, err := scanJSONString(data, i)
if err != nil {
return jsonObjectSpan{}, err
}
var key string
if err := json.Unmarshal(data[keyStart:keyEnd], &key); err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, keyEnd)
if i >= len(data) || data[i] != ':' {
return jsonObjectSpan{}, fmt.Errorf("expected colon after JSON object key")
}
valueStart := skipJSONSpace(data, i+1)
valueEnd, err := scanJSONValue(data, valueStart)
if err != nil {
return jsonObjectSpan{}, err
}
i = skipJSONSpace(data, valueEnd)
member := jsonMemberSpan{
key: key,
leadingStart: leadingStart,
keyStart: keyStart,
valueStart: valueStart,
valueEnd: valueEnd,
commaAfter: -1,
}
if i < len(data) && data[i] == ',' {
member.commaAfter = i
object.members = append(object.members, member)
leadingStart = i + 1
continue
}
if i >= len(data) || data[i] != '}' {
return jsonObjectSpan{}, fmt.Errorf("expected comma or end of JSON object")
}
object.members = append(object.members, member)
object.end = i
return object, nil
}
}
func scanJSONString(data []byte, start int) (int, error) {
for i := start + 1; i < len(data); i++ {
switch data[i] {
case '\\':
i++
case '"':
return i + 1, nil
}
}
return 0, fmt.Errorf("unterminated JSON string")
}
func scanJSONValue(data []byte, start int) (int, error) {
if start >= len(data) {
return 0, fmt.Errorf("missing JSON value")
}
if data[start] == '"' {
return scanJSONString(data, start)
}
if data[start] == '{' || data[start] == '[' {
stack := []byte{data[start]}
for i := start + 1; i < len(data); i++ {
if data[i] == '"' {
end, err := scanJSONString(data, i)
if err != nil {
return 0, err
}
i = end - 1
continue
}
switch data[i] {
case '{', '[':
stack = append(stack, data[i])
case '}', ']':
open := stack[len(stack)-1]
if (open == '{' && data[i] != '}') || (open == '[' && data[i] != ']') {
return 0, fmt.Errorf("mismatched JSON delimiter")
}
stack = stack[:len(stack)-1]
if len(stack) == 0 {
return i + 1, nil
}
}
}
return 0, fmt.Errorf("unterminated JSON value")
}
for i := start; i < len(data); i++ {
switch data[i] {
case ',', '}', ']', ' ', '\t', '\r', '\n':
if i == start {
return 0, fmt.Errorf("missing JSON value")
}
return i, nil
}
}
return len(data), nil
}
func skipJSONSpace(data []byte, start int) int {
for start < len(data) {
switch data[start] {
case ' ', '\t', '\r', '\n':
start++
default:
return start
}
}
return start
}
func lastJSONMember(members []jsonMemberSpan, key string) int {
for i := len(members) - 1; i >= 0; i-- {
if members[i].key == key {
return i
}
}
return -1
}
func petJSONObject(encodedPet []byte) []byte {
result := append([]byte(`{"pet":`), encodedPet...)
return append(result, '}')
}
func replaceJSONRange(data []byte, start, end int, replacement []byte) []byte {
result := make([]byte, 0, len(data)-(end-start)+len(replacement))
result = append(result, data[:start]...)
result = append(result, replacement...)
return append(result, data[end:]...)
}
func removeJSONMember(data []byte, object jsonObjectSpan, index int) []byte {
member := object.members[index]
start, end := member.leadingStart, member.valueEnd
if member.commaAfter >= 0 {
end = member.commaAfter + 1
} else if index > 0 {
start = object.members[index-1].commaAfter
}
return replaceJSONRange(data, start, end, nil)
}
func insertJSONMember(data []byte, object jsonObjectSpan, key string, value []byte) []byte {
encodedKey, _ := json.Marshal(key)
entry := append(append(encodedKey, ':', ' '), value...)
if len(object.members) == 0 {
interior := data[object.start+1 : object.end]
if newline := bytes.LastIndexByte(interior, '\n'); newline >= 0 {
closingIndent := interior[newline+1:]
indentUnit := []byte(" ")
if bytes.Contains(closingIndent, []byte("\t")) {
indentUnit = []byte("\t")
}
lineBreak := []byte("\n")
if newline > 0 && interior[newline-1] == '\r' {
lineBreak = []byte("\r\n")
}
insert := append(append(append(lineBreak, closingIndent...), indentUnit...), entry...)
return replaceJSONRange(data, object.start+1, object.start+1, insert)
}
return replaceJSONRange(data, object.end, object.end, entry)
}
last := object.members[len(object.members)-1]
separator := []byte(", ")
if bytes.Contains(data[last.valueEnd:object.end], []byte("\n")) {
lineStart := bytes.LastIndexByte(data[:last.keyStart], '\n') + 1
indent := data[lineStart:last.keyStart]
separator = append([]byte(",\n"), indent...)
}
insert := append(separator, entry...)
return replaceJSONRange(data, last.valueEnd, last.valueEnd, insert)
}
+252
View File
@@ -0,0 +1,252 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetPetPreservesUnrelatedConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := FileConfig{
ActiveProvider: "test",
Providers: []ProviderProfile{{Name: "test", Model: "example"}},
Preferences: PreferencesConfig{Theme: "dracula"},
}
data, err := json.Marshal(original)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
updated, err := SetPet(path, "boba")
if err != nil {
t.Fatal(err)
}
if updated.Preferences.Pet != "boba" || updated.Preferences.Theme != "dracula" || updated.ActiveProvider != "test" || len(updated.Providers) != 1 {
t.Fatalf("SetPet lost config fields: %#v", updated)
}
reloadedData, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var reloaded FileConfig
if err := json.Unmarshal(reloadedData, &reloaded); err != nil {
t.Fatal(err)
}
if reloaded.Preferences.Pet != "boba" || reloaded.Preferences.Theme != "dracula" || reloaded.ActiveProvider != "test" || len(reloaded.Providers) != 1 {
t.Fatalf("persisted config lost fields: %#v", reloaded)
}
}
func TestSetPetRejectsInvalidInputs(t *testing.T) {
if _, err := SetPet(" ", "boba"); err == nil {
t.Fatal("SetPet accepted a blank config path")
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"preferences":`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err == nil || !strings.Contains(err.Error(), "invalid config JSON") {
t.Fatalf("SetPet malformed JSON error = %v", err)
}
}
func TestSetPetCreatesNestedConfigAndClearsEmptyPreferences(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "zero", "config.json")
if _, err := SetPet(path, " boba "); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var preferences struct {
Pet string `json:"pet"`
}
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
if preferences.Pet != "boba" {
t.Fatalf("persisted pet = %q, want %q", preferences.Pet, "boba")
}
root["future"] = json.RawMessage(`{"enabled":true}`)
encoded, err := json.Marshal(root)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, " "); err != nil {
t.Fatal(err)
}
data, err = os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
root = nil
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
if _, ok := root["preferences"]; ok {
t.Fatalf("empty preferences were retained: %s", data)
}
var future struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["future"], &future); err != nil {
t.Fatal(err)
}
if !future.Enabled {
t.Fatalf("unrelated future member was lost: %s", data)
}
}
func TestSetPetPreservesUnknownJSONMembers(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte(`{
"activeProvider": "test",
"futureTopLevel": {"enabled": true},
"preferences": {
"theme": "dracula",
"futurePreference": {"mode": "extended"}
}
}`)
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(data, &root); err != nil {
t.Fatal(err)
}
var futureTopLevel struct {
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(root["futureTopLevel"], &futureTopLevel); err != nil {
t.Fatal(err)
}
if !futureTopLevel.Enabled {
t.Fatalf("unknown top-level member = %s", root["futureTopLevel"])
}
var preferences map[string]json.RawMessage
if err := json.Unmarshal(root["preferences"], &preferences); err != nil {
t.Fatal(err)
}
var futurePreference struct {
Mode string `json:"mode"`
}
if err := json.Unmarshal(preferences["futurePreference"], &futurePreference); err != nil {
t.Fatal(err)
}
if futurePreference.Mode != "extended" {
t.Fatalf("unknown preference member = %s", preferences["futurePreference"])
}
if got := string(preferences["pet"]); got != `"boba"` {
t.Fatalf("pet preference = %s, want boba", got)
}
}
func TestSetPetPreservesExistingJSONLayoutAndMemberOrder(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
original := []byte("{\n\t\"zFuture\": {\"enabled\":true},\n\t\"preferences\": {\n\t\t\"theme\" : \"dracula\",\n\t\t\"pet\" : \"old\",\n\t\t\"futurePreference\": {\"mode\":\"extended\"}\n\t},\n\t\"activeProvider\": \"test\"\n}\n")
if err := os.WriteFile(path, original, 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, "boba"); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
want := strings.Replace(string(original), `"old"`, `"boba"`, 1)
if string(got) != want {
t.Fatalf("SetPet reformatted or reordered config:\n--- got ---\n%s--- want ---\n%s", got, want)
}
}
func TestSetPetMinimallyEditsExistingJSON(t *testing.T) {
tests := []struct {
name string
original string
pet string
want string
}{
{
name: "add pet to existing preferences",
original: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": {\"text\":\"comma, brace } and \\\"quote\\\"\"},\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "add preferences without reordering root",
original: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\",\n \"preferences\": {\"pet\":\"boba\"}\n}\n",
},
{
name: "remove pet but retain preferences",
original: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"pet\": \"boba\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
pet: "",
want: "{\n \"preferences\": {\n \"theme\": \"dracula\",\n \"future\": true\n },\n \"zFuture\": true\n}\n",
},
{
name: "remove empty preferences member",
original: "{\n \"zFuture\": true,\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "",
want: "{\n \"zFuture\": true,\n \"activeProvider\": \"test\"\n}\n",
},
{
name: "do not expose an older duplicate preference",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{\"pet\":\"dog\"}}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":{}}\n",
},
{
name: "do not expose an older duplicate behind null",
original: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
pet: "",
want: "{\"preferences\":{\"pet\":\"cat\"},\"preferences\":null}\n",
},
{
name: "preserve multiline empty preferences layout",
original: "{\n \"preferences\": {\n },\n \"activeProvider\": \"test\"\n}\n",
pet: "boba",
want: "{\n \"preferences\": {\n \"pet\": \"boba\"\n },\n \"activeProvider\": \"test\"\n}\n",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(test.original), 0o600); err != nil {
t.Fatal(err)
}
if _, err := SetPet(path, test.pet); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(got) != test.want {
t.Fatalf("SetPet output:\n--- got ---\n%s--- want ---\n%s", got, test.want)
}
})
}
}
+3
View File
@@ -273,6 +273,9 @@ func mergeConfig(dst *FileConfig, src FileConfig) {
if strings.TrimSpace(src.Preferences.Theme) != "" {
dst.Preferences.Theme = strings.TrimSpace(src.Preferences.Theme)
}
if strings.TrimSpace(src.Preferences.Pet) != "" {
dst.Preferences.Pet = strings.TrimSpace(src.Preferences.Pet)
}
mergeLocalControlConfig(&dst.LocalControl, src.LocalControl)
mergeKeyBindings(&dst.KeyBindings, src.KeyBindings)
mergeSTTConfig(&dst.STT, src.STT)
+16
View File
@@ -168,6 +168,22 @@ func TestResolveLoadsRecentModelsFromUserConfigOnly(t *testing.T) {
}
}
func TestResolveLoadsPetFromUserConfigOnly(t *testing.T) {
userPath := writeConfig(t, `{"preferences":{"pet":" boba "}}`)
projectPath := writeConfig(t, `{"preferences":{"pet":"project-pet"}}`)
resolved, err := Resolve(ResolveOptions{
UserConfigPath: userPath,
ProjectConfigPath: projectPath,
Env: map[string]string{},
})
if err != nil {
t.Fatal(err)
}
if resolved.Preferences.Pet != "boba" {
t.Fatalf("Preferences.Pet = %q, want user preference boba", resolved.Preferences.Pet)
}
}
// recentModels must never be merged from project config (same posture as
// favoriteModels): a project setting it with no corresponding user setting
// should leave the resolved value empty, not pick up the project's list.
+3
View File
@@ -112,6 +112,9 @@ type PreferencesConfig struct {
// name (e.g. "dracula"). Applied at startup below the --theme flag and
// ZERO_THEME, so a /theme choice survives restart. Empty = unset (defaults auto).
Theme string `json:"theme,omitempty"`
// Pet is the terminal companion selected through /pets. Empty leaves pets
// off until the user chooses one; "disabled" is the explicit off state.
Pet string `json:"pet,omitempty"`
// Recaps is a tri-state: nil (unset) defaults to ON; an explicit false means
// the user turned idle recaps off. A *bool is its own tri-state, so no
// custom unmarshal is needed (unlike ToolsConfig.DeferThreshold's int).
+39 -4
View File
@@ -591,6 +591,35 @@ func SetTheme(path string, theme string) (FileConfig, error) {
return cfg, nil
}
// SetPet persists only the terminal-pet preference while preserving every
// unrelated user setting through the config writer's atomic replace path.
func SetPet(path string, pet string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
cfg := FileConfig{}
data := []byte("{}")
if existing, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(existing, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
data = existing
} else if !os.IsNotExist(err) {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
pet = strings.TrimSpace(pet)
cfg.Preferences.Pet = pet
data, err := setPetPreferenceJSON(data, pet)
if err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
if err := writeConfigData(path, data); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// SetSTTModel persists the dictation model and its provider, mirroring
// SetTheme (read-modify-atomic-write). provider must be one of the known STT
// provider kinds; a local provider stores the model as stt.localModelPath,
@@ -736,17 +765,23 @@ func NormalizeRecentModels(entries []RecentModelEntry) []RecentModelEntry {
}
func writeConfigFile(path string, cfg FileConfig) error {
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
}
return writeConfigData(path, data)
}
func writeConfigData(path string, data []byte) error {
dir := filepath.Dir(path)
if dir != "." && dir != "" {
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create config directory %s: %w", dir, err)
}
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config JSON: %w", err)
if len(data) == 0 || data[len(data)-1] != '\n' {
data = append(data, '\n')
}
data = append(data, '\n')
// Write-to-temp + rename: an in-place write interrupted mid-way (crash,
// disk full) would leave the user's only config truncated or corrupt.
tmp, err := os.CreateTemp(dir, ".zero-config-*.tmp")
+828
View File
@@ -0,0 +1,828 @@
package terminalpet
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
_ "golang.org/x/image/webp"
"github.com/Gitlawb/zero/internal/installtxn"
)
const (
manifestCacheAge = 10 * time.Minute
rankingCacheAge = 10 * time.Minute
maxManifestBytes = 2 << 20
maxRankingBytes = 2 << 20
maxPreviewBytes = 2 << 20
maxSpriteBytes = 16 << 20
maxPetJSONBytes = 1 << 20
maxImageSide = 16384
maxImagePixels = 50_000_000
)
type Client struct {
RootDir string
ManifestURL string
RankingURL string
HTTPClient *http.Client
TrustedHosts map[string]bool
// TrustedAssetHosts is narrower than TrustedHosts: catalog redirects may use
// petdex.dev, while image and metadata downloads must stay on the asset host.
TrustedAssetHosts map[string]bool
Now func() time.Time
}
type compactManifest struct {
Version int `json:"v"`
AssetBase string `json:"assetBase"`
Fields []string `json:"fields"`
Pets []json.RawMessage `json:"pets"`
}
type installedMetadata struct {
Entry
InstalledAt time.Time `json:"installedAt"`
}
type petMetadata struct {
SpriteVersion int `json:"spriteVersionNumber"`
Animations map[string]petAnimationMetadata `json:"animations"`
Interactions struct {
Click struct {
Animations []string `json:"animations"`
} `json:"click"`
} `json:"interactions"`
}
type petAnimationMetadata struct {
SourceRow json.RawMessage `json:"sourceRow"`
SourceRowIndex *int `json:"sourceRowIndex"`
FrameCount int `json:"frameCount"`
TimingMS []int `json:"timingMs"`
Playback string `json:"playback"`
Loop bool `json:"loop"`
}
func NewClient(rootDir string) *Client {
client := &Client{
RootDir: rootDir,
ManifestURL: DefaultManifestURL,
RankingURL: DefaultRankingURL,
TrustedHosts: map[string]bool{
"petdex.dev": true,
TrustedAssetHost: true,
},
TrustedAssetHosts: map[string]bool{TrustedAssetHost: true},
Now: time.Now,
}
client.HTTPClient = &http.Client{Timeout: 20 * time.Second}
return client
}
func (c *Client) Catalog(ctx context.Context) ([]Entry, error) {
remote, remoteErr := c.remoteCatalog(ctx)
local, localErr := c.localCatalog()
entries := mergeEntries(local, remote)
if len(entries) > 0 {
rankingCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
ranking, _ := c.installedRanking(rankingCtx)
cancel()
sortEntriesByRanking(entries, ranking)
return entries, nil
}
return nil, errors.Join(remoteErr, localErr)
}
func (c *Client) InstalledEntries() ([]Entry, error) {
return c.localCatalog()
}
type rankingPayload struct {
Pets []struct {
Slug string `json:"slug"`
} `json:"pets"`
}
func (c *Client) installedRanking(ctx context.Context) ([]string, error) {
cachePath := filepath.Join(c.cacheDir(), "ranking-installed.json")
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < rankingCacheAge {
if data, readErr := os.ReadFile(cachePath); readErr == nil {
if ranking, decodeErr := decodeRanking(data); decodeErr == nil {
return ranking, nil
}
}
}
data, err := c.fetch(ctx, c.RankingURL, maxRankingBytes)
if err == nil {
ranking, decodeErr := decodeRanking(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return ranking, nil
}
err = decodeErr
}
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
if ranking, decodeErr := decodeRanking(cached); decodeErr == nil {
return ranking, nil
}
}
return nil, fmt.Errorf("load pet ranking: %w", err)
}
func decodeRanking(data []byte) ([]string, error) {
var payload rankingPayload
if err := json.Unmarshal(data, &payload); err != nil {
return nil, fmt.Errorf("decode pet ranking: %w", err)
}
ranking := make([]string, 0, len(payload.Pets))
seen := make(map[string]bool, len(payload.Pets))
for _, pet := range payload.Pets {
slug := strings.TrimSpace(pet.Slug)
if validateSlug(slug) != nil || seen[slug] {
continue
}
seen[slug] = true
ranking = append(ranking, slug)
}
if len(ranking) == 0 {
return nil, fmt.Errorf("pet ranking is empty")
}
return ranking, nil
}
func (c *Client) Preview(ctx context.Context, entry Entry) (*Animation, error) {
if entry.Local {
if animation, err := c.LoadInstalled(entry.Slug); err == nil {
return animation, nil
}
}
if err := validateSlug(entry.Slug); err != nil {
return nil, err
}
cachePath := filepath.Join(c.previewDir(), previewCacheName(entry))
if data, err := os.ReadFile(cachePath); err == nil {
if animation, err := decodePreview(data); err == nil {
return animation, nil
}
}
assetBase, err := c.trustedAssetBase(entry.AssetBase)
if err != nil {
return nil, err
}
previewURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/preview.webp"
data, err := c.fetchAsset(ctx, previewURL, maxPreviewBytes)
if err == nil {
animation, decodeErr := decodePreview(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return animation, nil
}
err = decodeErr
}
thumbnailURL := assetBase + "/pets/" + url.PathEscape(entry.Slug) + "/thumb.webp"
thumbnail, thumbnailErr := c.fetchAsset(ctx, thumbnailURL, maxPreviewBytes)
if thumbnailErr != nil {
return nil, errors.Join(err, thumbnailErr)
}
imageValue, decodeErr := decodeImage(thumbnail)
if decodeErr != nil {
return nil, decodeErr
}
return ThumbnailAnimation(imageValue)
}
func (c *Client) Install(ctx context.Context, entry Entry) (*Animation, error) {
if err := validateSlug(entry.Slug); err != nil {
return nil, err
}
if entry.Local {
return c.LoadInstalled(entry.Slug)
}
spriteURL, err := c.trustedAssetURL(entry.SpritesheetURL)
if err != nil {
return nil, fmt.Errorf("invalid spritesheet URL: %w", err)
}
spriteData, err := c.fetchAsset(ctx, spriteURL, maxSpriteBytes)
if err != nil {
return nil, fmt.Errorf("download spritesheet: %w", err)
}
sheet, err := decodeImage(spriteData)
if err != nil {
return nil, fmt.Errorf("decode spritesheet: %w", err)
}
var petJSON []byte
var animationTracks map[State]atlasTrack
var document petMetadata
if strings.TrimSpace(entry.PetJSONURL) != "" {
petURL, urlErr := c.trustedAssetURL(entry.PetJSONURL)
if urlErr != nil {
return nil, fmt.Errorf("invalid pet metadata URL: %w", urlErr)
}
petJSON, err = c.fetchAsset(ctx, petURL, maxPetJSONBytes)
if err != nil {
return nil, fmt.Errorf("download pet metadata: %w", err)
}
if err := json.Unmarshal(petJSON, &document); err != nil {
return nil, fmt.Errorf("invalid pet metadata: %w", err)
}
if document.SpriteVersion != 0 {
if document.SpriteVersion != 1 && document.SpriteVersion != 2 {
return nil, fmt.Errorf("invalid pet metadata: unsupported sprite version %d", document.SpriteVersion)
}
entry.SpriteVersion = document.SpriteVersion
}
animationTracks, err = document.atlasTracks()
if err != nil {
return nil, fmt.Errorf("invalid pet metadata: %w", err)
}
}
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
if err != nil {
return nil, err
}
animation.setClickAnimations(document.Interactions.Click.Animations)
root := c.installedDir()
stage, cleanup, err := installtxn.StageDir(root)
if err != nil {
return nil, err
}
defer cleanup()
if err := os.MkdirAll(stage, 0o700); err != nil {
return nil, fmt.Errorf("create pet staging directory: %w", err)
}
if err := os.WriteFile(filepath.Join(stage, "spritesheet.webp"), spriteData, 0o600); err != nil {
return nil, fmt.Errorf("stage spritesheet: %w", err)
}
if len(petJSON) > 0 {
if err := os.WriteFile(filepath.Join(stage, "pet.json"), petJSON, 0o600); err != nil {
return nil, fmt.Errorf("stage pet metadata: %w", err)
}
}
metadata, err := json.MarshalIndent(installedMetadata{Entry: entry, InstalledAt: c.now()}, "", " ")
if err != nil {
return nil, err
}
if err := os.WriteFile(filepath.Join(stage, "source.json"), append(metadata, '\n'), 0o600); err != nil {
return nil, fmt.Errorf("stage pet source: %w", err)
}
unlock, err := installtxn.Lock(root)
if err != nil {
return nil, err
}
defer unlock()
target := filepath.Join(root, entry.Slug)
if err := installtxn.CommitDir(target, stage, func() error { return nil }); err != nil {
return nil, fmt.Errorf("install pet: %w", err)
}
return animation, nil
}
func (c *Client) LoadInstalled(slug string) (*Animation, error) {
if err := validateSlug(slug); err != nil {
return nil, err
}
dir := filepath.Join(c.installedDir(), slug)
entry, err := c.InstalledEntry(slug)
if err != nil {
return nil, err
}
spriteData, err := os.ReadFile(filepath.Join(dir, "spritesheet.webp"))
if err != nil {
return nil, fmt.Errorf("read installed spritesheet: %w", err)
}
sheet, err := decodeImage(spriteData)
if err != nil {
return nil, err
}
var animationTracks map[State]atlasTrack
var document petMetadata
if petJSON, readErr := os.ReadFile(filepath.Join(dir, "pet.json")); readErr == nil {
if decodeErr := json.Unmarshal(petJSON, &document); decodeErr != nil {
return nil, fmt.Errorf("read installed pet metadata: %w", decodeErr)
}
if document.SpriteVersion != 0 {
entry.SpriteVersion = document.SpriteVersion
}
animationTracks, err = document.atlasTracks()
if err != nil {
return nil, fmt.Errorf("read installed pet metadata: %w", err)
}
} else if !errors.Is(readErr, os.ErrNotExist) {
return nil, fmt.Errorf("read installed pet metadata: %w", readErr)
}
animation, err := atlasAnimation(sheet, entry.SpriteVersion, animationTracks)
if err != nil {
return nil, err
}
animation.setClickAnimations(document.Interactions.Click.Animations)
return animation, nil
}
func (m petMetadata) atlasTracks() (map[State]atlasTrack, error) {
names := make([]string, 0, len(m.Animations))
for name := range m.Animations {
names = append(names, name)
}
sort.Strings(names)
tracks := make(map[State]atlasTrack)
for _, name := range names {
spec := m.Animations[name]
state := State(strings.ToLower(strings.TrimSpace(name)))
if state == "" {
return nil, fmt.Errorf("animation name is empty")
}
if spec.FrameCount < 1 || spec.FrameCount > atlasColumns {
return nil, fmt.Errorf("animation %q has invalid frame count %d", name, spec.FrameCount)
}
if len(spec.TimingMS) != 0 && len(spec.TimingMS) != spec.FrameCount {
return nil, fmt.Errorf("animation %q has %d timings for %d frames", name, len(spec.TimingMS), spec.FrameCount)
}
row, err := spec.rowIndex()
if err != nil {
return nil, fmt.Errorf("animation %q: %w", name, err)
}
// Idle must always cycle. Treating an authored idle track as one-shot
// leaves the companion frozen on its final frame indefinitely.
loop := state == Idle || spec.Loop || strings.EqualFold(strings.TrimSpace(spec.Playback), "loop")
track := atlasTrack{row: row, count: spec.FrameCount, loop: loop, fallbackIdle: !loop}
if len(spec.TimingMS) > 0 {
track.durations = make([]time.Duration, len(spec.TimingMS))
for index, milliseconds := range spec.TimingMS {
if milliseconds < 16 || milliseconds > 10_000 {
return nil, fmt.Errorf("animation %q has invalid frame timing %dms", name, milliseconds)
}
track.durations[index] = time.Duration(milliseconds) * time.Millisecond
}
}
tracks[state] = track
}
return tracks, nil
}
func (m petAnimationMetadata) rowIndex() (int, error) {
if m.SourceRowIndex != nil {
return *m.SourceRowIndex, nil
}
if len(m.SourceRow) == 0 || string(m.SourceRow) == "null" {
return 0, fmt.Errorf("source row is missing")
}
var numeric int
if err := json.Unmarshal(m.SourceRow, &numeric); err == nil {
return numeric, nil
}
var name string
if err := json.Unmarshal(m.SourceRow, &name); err != nil {
return 0, fmt.Errorf("source row is invalid")
}
rows := map[string]int{
"idle": 0, "running-right": 1, "running-left": 2, "waving": 3,
"jumping": 4, "failed": 5, "waiting": 6, "running": 7, "review": 8,
}
normalized := strings.ToLower(strings.TrimSpace(name))
if row, ok := rows[normalized]; ok {
return row, nil
}
if row, err := strconv.Atoi(normalized); err == nil {
return row, nil
}
return 0, fmt.Errorf("unknown source row %q", name)
}
func (c *Client) InstalledEntry(slug string) (Entry, error) {
if err := validateSlug(slug); err != nil {
return Entry{}, err
}
metadataData, err := os.ReadFile(filepath.Join(c.installedDir(), slug, "source.json"))
if err != nil {
return Entry{}, fmt.Errorf("read installed pet: %w", err)
}
var metadata installedMetadata
if err := json.Unmarshal(metadataData, &metadata); err != nil {
return Entry{}, fmt.Errorf("read installed pet metadata: %w", err)
}
if metadata.Slug != slug {
return Entry{}, fmt.Errorf("installed pet metadata does not match %q", slug)
}
metadata.Local = true
return metadata.Entry, nil
}
func (c *Client) remoteCatalog(ctx context.Context) ([]Entry, error) {
cachePath := filepath.Join(c.cacheDir(), "petdex-v2.json")
if info, err := os.Stat(cachePath); err == nil && c.now().Sub(info.ModTime()) < manifestCacheAge {
if data, readErr := os.ReadFile(cachePath); readErr == nil {
if entries, decodeErr := c.decodeCatalog(data); decodeErr == nil {
return entries, nil
}
}
}
data, err := c.fetch(ctx, c.ManifestURL, maxManifestBytes)
if err == nil {
entries, decodeErr := c.decodeCatalog(data)
if decodeErr == nil {
_ = c.cacheFile(cachePath, data)
return entries, nil
}
err = decodeErr
}
if cached, readErr := os.ReadFile(cachePath); readErr == nil {
if entries, decodeErr := c.decodeCatalog(cached); decodeErr == nil {
return entries, nil
}
}
return nil, fmt.Errorf("load pet catalog: %w", err)
}
func (c *Client) decodeCatalog(data []byte) ([]Entry, error) {
var manifest compactManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("decode pet catalog: %w", err)
}
if manifest.Version != 2 {
return nil, fmt.Errorf("unsupported pet catalog version %d", manifest.Version)
}
assetBase, err := c.trustedAssetBase(manifest.AssetBase)
if err != nil {
return nil, err
}
wanted := map[string]int{}
for index, field := range manifest.Fields {
wanted[field] = index
}
required := []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"}
for _, field := range required {
if _, ok := wanted[field]; !ok {
return nil, fmt.Errorf("pet catalog is missing %q", field)
}
}
entries := make([]Entry, 0, len(manifest.Pets))
seen := map[string]bool{}
var firstRowError error
for _, rawRow := range manifest.Pets {
entry, rowErr := func() (Entry, error) {
var row []json.RawMessage
if err := json.Unmarshal(rawRow, &row); err != nil {
return Entry{}, fmt.Errorf("decode pet catalog row: %w", err)
}
value := func(field string, target any) error {
index := wanted[field]
if index >= len(row) {
return fmt.Errorf("pet catalog row is missing %q", field)
}
return json.Unmarshal(row[index], target)
}
var entry Entry
if err := value("slug", &entry.Slug); err != nil {
return Entry{}, err
}
if err := validateSlug(entry.Slug); err != nil {
return Entry{}, err
}
if err := value("displayName", &entry.DisplayName); err != nil {
return Entry{}, err
}
if err := value("kind", &entry.Kind); err != nil {
return Entry{}, err
}
// submittedBy is nullable in the public manifest; null intentionally maps
// to an empty creator label.
_ = value("submittedBy", &entry.SubmittedBy)
if err := value("spritesheet", &entry.SpritesheetURL); err != nil {
return Entry{}, err
}
if err := value("petJson", &entry.PetJSONURL); err != nil {
return Entry{}, err
}
if err := value("spriteVersionNumber", &entry.SpriteVersion); err != nil {
return Entry{}, err
}
if entry.SpriteVersion != 1 && entry.SpriteVersion != 2 {
return Entry{}, fmt.Errorf("pet %q has unsupported sprite version %d", entry.Slug, entry.SpriteVersion)
}
entry.AssetBase = assetBase
resolved, err := c.resolveAssetURL(assetBase, entry.SpritesheetURL)
if err != nil {
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
}
entry.SpritesheetURL = resolved
if strings.TrimSpace(entry.PetJSONURL) != "" {
resolved, err = c.resolveAssetURL(assetBase, entry.PetJSONURL)
if err != nil {
return Entry{}, fmt.Errorf("pet %q: %w", entry.Slug, err)
}
entry.PetJSONURL = resolved
}
return entry, nil
}()
if rowErr != nil {
if firstRowError == nil {
firstRowError = rowErr
}
continue
}
if seen[entry.Slug] {
continue
}
seen[entry.Slug] = true
entries = append(entries, entry)
}
if len(entries) == 0 && firstRowError != nil {
return nil, firstRowError
}
sort.Slice(entries, func(i, j int) bool { return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label()) })
return entries, nil
}
func (c *Client) localCatalog() ([]Entry, error) {
dirs, err := os.ReadDir(c.installedDir())
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
entries := make([]Entry, 0, len(dirs))
for _, dir := range dirs {
if !dir.IsDir() || validateSlug(dir.Name()) != nil {
continue
}
entry, readErr := c.InstalledEntry(dir.Name())
if readErr != nil {
continue
}
entries = append(entries, entry)
}
return entries, nil
}
func (c *Client) fetch(ctx context.Context, address string, limit int64) ([]byte, error) {
return c.fetchLimit(ctx, address, limit, false)
}
func (c *Client) fetchAsset(ctx context.Context, address string, limit int64) ([]byte, error) {
return c.fetchLimit(ctx, address, limit, true)
}
func (c *Client) fetchLimit(ctx context.Context, address string, limit int64, assetOnly bool) ([]byte, error) {
if _, err := c.trustedURL(address); err != nil {
return nil, err
}
if assetOnly {
if _, err := c.trustedAssetURL(address); err != nil {
return nil, err
}
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil)
if err != nil {
return nil, err
}
baseClient := c.httpClient()
requestClient := *baseClient
requestClient.CheckRedirect = func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("too many redirects")
}
if assetOnly {
_, err := c.trustedAssetURL(request.URL.String())
return err
}
_, err := c.trustedURL(request.URL.String())
return err
}
response, err := requestClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if _, err := c.trustedURL(response.Request.URL.String()); err != nil {
return nil, err
}
if assetOnly {
if _, err := c.trustedAssetURL(response.Request.URL.String()); err != nil {
return nil, err
}
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request returned %s", response.Status)
}
reader := io.LimitReader(response.Body, limit+1)
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if int64(len(data)) > limit {
return nil, fmt.Errorf("download exceeds %d-byte limit", limit)
}
return data, nil
}
func (c *Client) trustedAssetBase(raw string) (string, error) {
parsed, err := c.trustedURL(raw)
if err != nil {
return "", fmt.Errorf("invalid asset base: %w", err)
}
if parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return "", fmt.Errorf("asset base must not contain a path, query, or fragment")
}
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
}
return strings.TrimSuffix(parsed.String(), "/"), nil
}
func (c *Client) trustedAssetURL(raw string) (string, error) {
parsed, err := c.trustedURL(raw)
if err != nil {
return "", err
}
if !c.assetHosts()[strings.ToLower(parsed.Hostname())] {
return "", fmt.Errorf("untrusted asset host %q", parsed.Hostname())
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("asset URL must not contain a query or fragment")
}
cleaned := path.Clean(parsed.Path)
escaped := strings.ToLower(parsed.EscapedPath())
if !allowedAssetPath(cleaned) || cleaned != parsed.Path || strings.Contains(escaped, "%2f") || strings.Contains(escaped, "%5c") || strings.Contains(parsed.Path, "\\") {
return "", fmt.Errorf("asset path is outside the supported catalog roots")
}
return parsed.String(), nil
}
func (c *Client) resolveAssetURL(assetBase, reference string) (string, error) {
reference = strings.TrimSpace(reference)
if reference == "" {
return "", fmt.Errorf("asset path is empty")
}
if parsed, err := url.Parse(reference); err == nil && parsed.IsAbs() {
return c.trustedAssetURL(reference)
}
parsed, err := url.Parse(reference)
if err != nil || parsed.Host != "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return "", fmt.Errorf("invalid relative asset path")
}
cleaned := path.Clean("/" + strings.TrimPrefix(parsed.Path, "/"))
if !allowedAssetPath(cleaned) || strings.Contains(parsed.Path, "\\") {
return "", fmt.Errorf("asset path is outside the supported catalog roots")
}
return c.trustedAssetURL(strings.TrimSuffix(assetBase, "/") + cleaned)
}
func allowedAssetPath(value string) bool {
return strings.HasPrefix(value, "/pets/") || strings.HasPrefix(value, "/community/") || strings.HasPrefix(value, "/curated/")
}
func (c *Client) assetHosts() map[string]bool {
if c.TrustedAssetHosts != nil {
return c.TrustedAssetHosts
}
return map[string]bool{TrustedAssetHost: true}
}
func (c *Client) trustedURL(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme != "https" || parsed.User != nil || parsed.Hostname() == "" {
return nil, fmt.Errorf("URL must use HTTPS without credentials")
}
hosts := c.TrustedHosts
if hosts == nil {
hosts = map[string]bool{"petdex.dev": true, TrustedAssetHost: true}
}
if !hosts[strings.ToLower(parsed.Hostname())] {
return nil, fmt.Errorf("untrusted host %q", parsed.Hostname())
}
return parsed, nil
}
func decodePreview(data []byte) (*Animation, error) {
imageValue, err := decodeImage(data)
if err != nil {
return nil, err
}
return PreviewAnimation(imageValue)
}
func previewCacheName(entry Entry) string {
digest := sha256.Sum256([]byte(entry.SpritesheetURL))
return fmt.Sprintf("%s-%x.webp", entry.Slug, digest[:6])
}
func decodeImage(data []byte) (image.Image, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image header: %w", err)
}
if config.Width < 1 || config.Height < 1 || config.Width > maxImageSide || config.Height > maxImageSide || int64(config.Width)*int64(config.Height) > maxImagePixels {
return nil, fmt.Errorf("image dimensions %dx%d exceed limits", config.Width, config.Height)
}
imageValue, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("decode image: %w", err)
}
return imageValue, nil
}
func (c *Client) cacheFile(target string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil {
return err
}
return installtxn.WriteFileAtomically(target, data, 0o600)
}
func (c *Client) httpClient() *http.Client {
if c.HTTPClient != nil {
return c.HTTPClient
}
client := &http.Client{Timeout: 20 * time.Second}
return client
}
func (c *Client) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
func (c *Client) cacheDir() string { return filepath.Join(c.RootDir, "pets", "cache") }
func (c *Client) previewDir() string { return filepath.Join(c.cacheDir(), "previews") }
func (c *Client) installedDir() string { return filepath.Join(c.RootDir, "pets", "installed") }
func validateSlug(slug string) error {
if slug == "" || len(slug) > 128 {
return fmt.Errorf("invalid pet slug %q", slug)
}
for index, value := range slug {
if (value >= 'a' && value <= 'z') || (value >= '0' && value <= '9') || (index > 0 && (value == '-' || value == '_')) {
continue
}
return fmt.Errorf("invalid pet slug %q", slug)
}
return nil
}
func mergeEntries(local, remote []Entry) []Entry {
bySlug := make(map[string]Entry, len(local)+len(remote))
for _, entry := range remote {
bySlug[entry.Slug] = entry
}
for _, entry := range local {
bySlug[entry.Slug] = entry
}
entries := make([]Entry, 0, len(bySlug))
for _, entry := range bySlug {
entries = append(entries, entry)
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Local != entries[j].Local {
return entries[i].Local
}
return strings.ToLower(entries[i].Label()) < strings.ToLower(entries[j].Label())
})
return entries
}
func sortEntriesByRanking(entries []Entry, ranking []string) {
rank := make(map[string]int, len(ranking))
for index, slug := range ranking {
rank[slug] = index
}
sort.SliceStable(entries, func(i, j int) bool {
left, right := entries[i], entries[j]
if left.Local != right.Local {
return left.Local
}
if left.Local {
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
}
leftRank, leftRanked := rank[left.Slug]
rightRank, rightRanked := rank[right.Slug]
if leftRanked != rightRanked {
return leftRanked
}
if leftRanked {
return leftRank < rightRank
}
return strings.ToLower(left.Label()) < strings.ToLower(right.Label())
})
}
+485
View File
@@ -0,0 +1,485 @@
package terminalpet
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestCatalogPreviewInstallAndOfflineReload(t *testing.T) {
preview := encodedPNG(t, 24*previewFrameCount, 26)
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
var assetBase atomic.Pointer[string]
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/manifest":
base := assetBase.Load()
if base == nil {
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
return
}
manifest := compactManifest{
Version: 2,
AssetBase: *base,
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
}
row := []any{"boba", "Boba", "animal", "tester", "pets/boba/sprite.webp", "pets/boba/petjson.json", "pets/boba/archive.zip", 2}
encoded, _ := json.Marshal(row)
manifest.Pets = []json.RawMessage{encoded}
_ = json.NewEncoder(writer).Encode(manifest)
case "/pets/boba/preview.webp":
_, _ = writer.Write(preview)
case "/pets/boba/sprite.webp":
_, _ = writer.Write(atlas)
case "/pets/boba/petjson.json":
_, _ = writer.Write([]byte(`{"displayName":"Boba","description":"A calm blue-screen gremlin."}`))
case "/ranking":
_, _ = writer.Write([]byte(`{"pets":[{"slug":"boba"}],"nextCursor":null}`))
default:
http.NotFound(writer, request)
}
}))
serverURL := server.URL
assetBase.Store(&serverURL)
defer server.Close()
root := t.TempDir()
client := testClient(t, root, server)
entries, err := client.Catalog(context.Background())
if err != nil || len(entries) != 1 {
t.Fatalf("Catalog() = %#v, %v", entries, err)
}
entry := entries[0]
if entry.Slug != "boba" || entry.SpriteVersion != 2 || entry.AssetBase != server.URL {
t.Fatalf("unexpected catalog entry: %#v", entry)
}
if entry.SpritesheetURL != server.URL+"/pets/boba/sprite.webp" {
t.Fatalf("relative spritesheet was not resolved: %q", entry.SpritesheetURL)
}
previewAnimation, err := client.Preview(context.Background(), entry)
if err != nil || previewAnimation.Frame(Idle, 5) == nil {
t.Fatalf("Preview() animation=%v err=%v", previewAnimation, err)
}
installed, err := client.Install(context.Background(), entry)
if err != nil || installed.Frame(Running, 3) == nil {
t.Fatalf("Install() animation=%v err=%v", installed, err)
}
if _, err := os.Stat(filepath.Join(root, "pets", "installed", "boba", "source.json")); err != nil {
t.Fatalf("installed source marker: %v", err)
}
server.Close()
offlineEntries, err := client.Catalog(context.Background())
if err != nil || len(offlineEntries) != 1 || !offlineEntries[0].Local {
t.Fatalf("offline Catalog() = %#v, %v", offlineEntries, err)
}
if _, err := client.LoadInstalled("boba"); err != nil {
t.Fatalf("LoadInstalled: %v", err)
}
}
func TestInstallUsesPetMetadataSpriteVersionWhenManifestIsStale(t *testing.T) {
atlas := encodedPNG(t, 24*atlasColumns, 26*11)
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/pets/luffy/sprite.webp":
_, _ = writer.Write(atlas)
case "/pets/luffy/petjson.json":
_, _ = writer.Write([]byte(`{
"spriteVersionNumber":2,
"atlasRows":11,
"interactions":{"click":{"animations":["bajrang-gun"],"mode":"cycle"}},
"animations":{"bajrang-gun":{"sourceRowIndex":3,"frameCount":8,"timingMs":[100,110,120,130,140,150,160,320]}}
}`))
default:
http.NotFound(writer, request)
}
}))
defer server.Close()
client := testClient(t, t.TempDir(), server)
entry := Entry{
Slug: "luffy",
DisplayName: "Luffy Gear 5",
SpritesheetURL: server.URL + "/pets/luffy/sprite.webp",
PetJSONURL: server.URL + "/pets/luffy/petjson.json",
SpriteVersion: 1,
}
animation, err := client.Install(context.Background(), entry)
if err != nil {
t.Fatalf("Install() with corrected pet metadata: %v", err)
}
if animation.Frame(Review, 0) == nil {
t.Fatal("Install() did not load the version 2 review row")
}
customState := State("bajrang-gun")
if _, key := animation.frame(customState, 7); key.index != 7 {
t.Fatalf("custom waving final frame index = %d, want 7", key.index)
}
if got := animation.FrameDelay(customState, 7); got != 320*time.Millisecond {
t.Fatalf("custom waving final frame delay = %s, want 320ms", got)
}
if got := animation.PrimaryDuration(customState); got != 1230*time.Millisecond {
t.Fatalf("custom waving duration = %s, want unchanged 1.23s", got)
}
if got := animation.FrameDelay(customState, 8); got != 1680*time.Millisecond {
t.Fatalf("custom one-shot fallback delay = %s, want first idle frame at 1.68s", got)
}
if got, ok := animation.ClickAnimation(0); !ok || got != customState {
t.Fatalf("click animation = %q, %v; want %q", got, ok, customState)
}
installed, err := client.InstalledEntry(entry.Slug)
if err != nil {
t.Fatal(err)
}
if installed.SpriteVersion != 2 {
t.Fatalf("persisted sprite version = %d, want 2", installed.SpriteVersion)
}
if _, err := client.LoadInstalled(entry.Slug); err != nil {
t.Fatalf("LoadInstalled() with corrected sprite version: %v", err)
}
}
func TestPetMetadataAllowsSeveralNamedAnimationsOnOneRow(t *testing.T) {
row := 0
document := petMetadata{Animations: map[string]petAnimationMetadata{
"blink": {SourceRowIndex: &row, FrameCount: 4, TimingMS: []int{100, 100, 100, 100}},
"idle": {SourceRow: json.RawMessage(`"idle"`), FrameCount: 6, TimingMS: []int{200, 200, 200, 200, 200, 400}},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
if got := tracks[Idle].count; got != 6 {
t.Fatalf("idle frame count = %d, want six", got)
}
if got := tracks[State("blink")].count; got != 4 {
t.Fatalf("blink frame count = %d, want four", got)
}
}
func TestPetMetadataAlwaysLoopsIdleAnimation(t *testing.T) {
row := 0
document := petMetadata{Animations: map[string]petAnimationMetadata{
"idle": {
SourceRowIndex: &row,
FrameCount: 2,
TimingMS: []int{100, 200},
Playback: "once",
},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
idle := tracks[Idle]
if !idle.loop || idle.fallbackIdle {
t.Fatalf("idle track = %#v, want looping without fallback", idle)
}
}
func TestPetMetadataAcceptsNamedAndNumericSourceRows(t *testing.T) {
document := petMetadata{Animations: map[string]petAnimationMetadata{
"dash": {SourceRow: json.RawMessage(`"running-right"`), FrameCount: 8},
"skill": {SourceRow: json.RawMessage(`3`), FrameCount: 8},
}}
tracks, err := document.atlasTracks()
if err != nil {
t.Fatal(err)
}
if tracks[State("dash")].row != 1 || tracks[State("skill")].row != 3 {
t.Fatalf("normalized rows = dash:%d skill:%d", tracks[State("dash")].row, tracks[State("skill")].row)
}
}
func TestCatalogRejectsUntrustedAssetHost(t *testing.T) {
client := NewClient(t.TempDir())
manifest := compactManifest{
Version: 2,
AssetBase: "https://evil.example",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
}
row, _ := json.Marshal([]any{"boba", "Boba", "animal", nil, "https://evil.example/pets/boba/sprite.webp", "", 2})
manifest.Pets = []json.RawMessage{row}
data, _ := json.Marshal(manifest)
if _, err := client.decodeCatalog(data); err == nil {
t.Fatal("decodeCatalog accepted an untrusted asset host")
}
}
func TestCatalogSkipsMalformedRowWhenValidRowsRemain(t *testing.T) {
client := NewClient(t.TempDir())
manifest := compactManifest{
Version: 2,
AssetBase: "https://assets.petdex.dev",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "spriteVersionNumber"},
}
invalid, _ := json.Marshal([]any{"broken slug", "Broken", "creature", "tester", "pets/broken/sprite.webp", "", 2})
valid, _ := json.Marshal([]any{"boba", "Boba", "creature", "tester", "pets/boba/sprite.webp", "", 2})
manifest.Pets = []json.RawMessage{invalid, valid}
data, _ := json.Marshal(manifest)
entries, err := client.decodeCatalog(data)
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"boba"}) {
t.Fatalf("catalog entries = %v, want [boba]", got)
}
}
func TestCatalogOrdersRankedPetsBeforeAlphabeticalRemainder(t *testing.T) {
manifest := compactManifest{
Version: 2,
AssetBase: "",
Fields: []string{"slug", "displayName", "kind", "submittedBy", "spritesheet", "petJson", "zip", "spriteVersionNumber"},
}
for _, row := range [][]any{
{"alpha", "Alpha", "creature", "tester", "pets/alpha/sprite.webp", "pets/alpha/pet.json", nil, 1},
{"beta", "Beta", "creature", "tester", "pets/beta/sprite.webp", "pets/beta/pet.json", nil, 1},
{"zeta", "Zeta", "creature", "tester", "pets/zeta/sprite.webp", "pets/zeta/pet.json", nil, 1},
} {
encoded, _ := json.Marshal(row)
manifest.Pets = append(manifest.Pets, encoded)
}
var rankingFails atomic.Bool
var assetBase atomic.Pointer[string]
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
switch request.URL.Path {
case "/manifest":
base := assetBase.Load()
if base == nil {
http.Error(writer, "server not ready", http.StatusServiceUnavailable)
return
}
manifest.AssetBase = *base
_ = json.NewEncoder(writer).Encode(manifest)
case "/ranking":
if rankingFails.Load() {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
return
}
_, _ = writer.Write([]byte(`{"pets":[{"slug":"zeta"},{"slug":"alpha"}],"nextCursor":60}`))
default:
http.NotFound(writer, request)
}
}))
serverURL := server.URL
assetBase.Store(&serverURL)
defer server.Close()
client := testClient(t, t.TempDir(), server)
entries, err := client.Catalog(context.Background())
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"zeta", "alpha", "beta"}) {
t.Fatalf("ranked catalog = %v", got)
}
rankingFails.Store(true)
fallback := testClient(t, t.TempDir(), server)
entries, err = fallback.Catalog(context.Background())
if err != nil {
t.Fatal(err)
}
if got := entrySlugs(entries); !slices.Equal(got, []string{"alpha", "beta", "zeta"}) {
t.Fatalf("fallback catalog = %v", got)
}
}
func TestAssetPathAndSlugValidation(t *testing.T) {
client := NewClient(t.TempDir())
for _, raw := range []string{
"https://assets.petdex.dev/other/sprite.webp",
"https://assets.petdex.dev/pets/%2e%2e/secret",
"https://[email protected]/pets/boba/sprite.webp",
} {
if _, err := client.trustedAssetURL(raw); err == nil {
t.Errorf("trustedAssetURL(%q) unexpectedly succeeded", raw)
}
}
for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", ""} {
if err := validateSlug(slug); err == nil {
t.Errorf("validateSlug(%q) unexpectedly succeeded", slug)
}
}
}
func TestAssetRedirectToUntrustedHostIsRejectedBeforeFollowing(t *testing.T) {
redirect := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
http.Redirect(writer, request, "https://example.com/pets/boba/sprite.webp", http.StatusFound)
}))
defer redirect.Close()
parsed, _ := url.Parse(redirect.URL)
client := NewClient(t.TempDir())
client.HTTPClient = redirect.Client()
client.TrustedHosts = map[string]bool{parsed.Hostname(): true}
client.TrustedAssetHosts = map[string]bool{parsed.Hostname(): true}
if _, err := client.fetchAsset(context.Background(), redirect.URL+"/pets/boba/sprite.webp", maxSpriteBytes); err == nil {
t.Fatal("fetchAsset followed a redirect to an untrusted host")
}
}
func TestResolveAssetURLRejectsPathsOutsideCatalogRoots(t *testing.T) {
client := NewClient(t.TempDir())
for _, reference := range []string{"../secret.png", "/other/sprite.webp", `pets\\boba\\sprite.webp`} {
if _, err := client.resolveAssetURL("https://assets.petdex.dev", reference); err == nil {
t.Errorf("resolveAssetURL(%q) unexpectedly succeeded", reference)
}
}
}
func TestFetchAssetRejectsOversizedDownload(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_, _ = writer.Write([]byte("12345"))
}))
defer server.Close()
client := testClient(t, t.TempDir(), server)
if _, err := client.fetchAsset(context.Background(), server.URL+"/pets/boba/sprite.webp", 4); err == nil || !strings.Contains(err.Error(), "exceeds") {
t.Fatalf("oversized fetch error = %v", err)
}
}
func TestDecodeImageRejectsOversizedDimensions(t *testing.T) {
data := encodedPNG(t, maxImageSide+1, 1)
if _, err := decodeImage(data); err == nil || !strings.Contains(err.Error(), "dimensions") {
t.Fatalf("oversized image error = %v", err)
}
}
func TestAtlasAnimationRejectsWrongGeometry(t *testing.T) {
bad := image.NewNRGBA(image.Rect(0, 0, 192, 285))
if _, err := AtlasAnimation(bad, 2); err == nil {
t.Fatal("AtlasAnimation accepted a non-grid image")
}
if _, err := AtlasAnimation(image.NewNRGBA(image.Rect(0, 0, 192, 286)), 3); err == nil {
t.Fatal("AtlasAnimation accepted an unknown sprite version")
}
}
func TestAtlasAnimationUsesPerStateFrameCounts(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
for column := 0; column < 6; column++ {
atlas.SetNRGBA(column*24, 0, color.NRGBA{R: 255, A: 255})
}
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
// Idle has six frames: phase 6 wraps to frame zero instead of entering the
// two unused atlas columns, which are commonly transparent.
_, _, _, alpha := animation.Frame(Idle, 6).At(0, 0).RGBA()
if alpha == 0 {
t.Fatal("idle phase 6 selected an unused transparent atlas column")
}
}
func TestAtlasAnimationRunningRepeatsThreeTimesThenLoopsIdle(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
for x := 0; x < 6*24; x++ {
for y := 0; y < 26; y++ {
atlas.SetNRGBA(x, y, color.NRGBA{R: 255, A: 255})
atlas.SetNRGBA(x, 7*26+y, color.NRGBA{B: 255, A: 255})
}
}
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
// The six-frame running row is authored to play three times. The next
// frame is idle, and subsequent phases loop only the appended idle row.
running := color.NRGBAModel.Convert(animation.Frame(Running, 17).At(0, 0)).(color.NRGBA)
settled := color.NRGBAModel.Convert(animation.Frame(Running, 18).At(0, 0)).(color.NRGBA)
looped := color.NRGBAModel.Convert(animation.Frame(Running, 24).At(0, 0)).(color.NRGBA)
if running.B != 255 || settled.R != 255 || looped.R != 255 {
t.Fatalf("running transition colors = running:%#v settled:%#v looped:%#v", running, settled, looped)
}
if got := animation.FrameDelay(Running, 18); got != 1680*time.Millisecond {
t.Fatalf("first fallback idle delay = %s, want 1.68s", got)
}
if got := animation.PrimaryDuration(Waving); got != 2100*time.Millisecond {
t.Fatalf("waving primary duration = %s, want 2.1s", got)
}
if got := animation.PrimaryDuration(Jumping); got != 2520*time.Millisecond {
t.Fatalf("jumping primary duration = %s, want 2.52s", got)
}
}
func TestAtlasAnimationSuppliesDefaultTimingForMetadataWithoutTiming(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
animation, err := atlasAnimation(atlas, 1, map[State]atlasTrack{
State("dash"): {row: 1, count: 3, fallbackIdle: true},
})
if err != nil {
t.Fatal(err)
}
if got := animation.FrameDelay(State("dash"), 0); got <= 0 {
t.Fatalf("metadata animation delay = %s, want a positive default", got)
}
}
func TestAtlasAnimationIncludesInteractiveStates(t *testing.T) {
atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
animation, err := AtlasAnimation(atlas, 1)
if err != nil {
t.Fatal(err)
}
for _, state := range []State{MoveRight, MoveLeft, Waving, Jumping} {
if frame := animation.Frame(state, 0); frame == nil {
t.Fatalf("interactive state %q has no frame", state)
}
}
}
func testClient(t *testing.T, root string, server *httptest.Server) *Client {
t.Helper()
parsed, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
host := parsed.Hostname()
client := NewClient(root)
client.ManifestURL = server.URL + "/manifest"
client.RankingURL = server.URL + "/ranking"
client.HTTPClient = server.Client()
client.TrustedHosts = map[string]bool{host: true}
client.TrustedAssetHosts = map[string]bool{host: true}
return client
}
func entrySlugs(entries []Entry) []string {
result := make([]string, len(entries))
for index, entry := range entries {
result[index] = entry.Slug
}
return result
}
func encodedPNG(t *testing.T, width, height int) []byte {
t.Helper()
value := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
value.SetNRGBA(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 180, A: 255})
}
}
var output bytes.Buffer
if err := png.Encode(&output, value); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
+421
View File
@@ -0,0 +1,421 @@
package terminalpet
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"image"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
xdraw "golang.org/x/image/draw"
"github.com/Gitlawb/zero/internal/installtxn"
)
const kittyChunkSize = 4096
type ImageProtocol uint8
const (
ImageProtocolNone ImageProtocol = iota
ImageProtocolKitty
ImageProtocolKittyLocalFile
ImageProtocolSixel
)
type ImageSupport struct {
Protocol ImageProtocol
Reason string
}
func (s ImageSupport) Supported() bool {
return s.Protocol != ImageProtocolNone
}
func DetectImageSupport(getenv func(string) string) ImageSupport {
if strings.TrimSpace(getenv("TMUX")) != "" || strings.TrimSpace(getenv("TMUX_PANE")) != "" {
return ImageSupport{Reason: "Terminal companions are disabled in tmux because terminal images are not reliably pane-local."}
}
if strings.TrimSpace(getenv("ZELLIJ")) != "" || strings.TrimSpace(getenv("ZELLIJ_SESSION_NAME")) != "" || strings.TrimSpace(getenv("ZELLIJ_VERSION")) != "" {
return ImageSupport{Reason: "Terminal companions are disabled in Zellij because terminal images are not reliably pane-local."}
}
if strings.TrimSpace(getenv("KITTY_WINDOW_ID")) != "" || strings.TrimSpace(getenv("WEZTERM_EXECUTABLE")) != "" || strings.TrimSpace(getenv("WEZTERM_VERSION")) != "" {
return ImageSupport{Protocol: ImageProtocolKitty}
}
term := strings.ToLower(getenv("TERM"))
program := strings.ToLower(getenv("TERM_PROGRAM"))
if strings.Contains(program, "iterm") {
if dottedVersionAtLeast(getenv("TERM_PROGRAM_VERSION"), 3, 6, 0) {
return ImageSupport{Protocol: ImageProtocolKittyLocalFile}
}
return ImageSupport{Reason: "Terminal companions require iTerm2 3.6 or newer."}
}
if strings.Contains(term, "ghostty") || strings.Contains(program, "ghostty") || strings.TrimSpace(getenv("GHOSTTY_RESOURCES_DIR")) != "" ||
strings.Contains(term, "kitty") || strings.Contains(program, "kitty") || strings.Contains(term, "wezterm") || strings.Contains(program, "wezterm") {
return ImageSupport{Protocol: ImageProtocolKitty}
}
if strings.TrimSpace(getenv("WT_SESSION")) != "" || strings.Contains(term, "sixel") || strings.Contains(term, "mlterm") || strings.Contains(term, "foot") {
return ImageSupport{Protocol: ImageProtocolSixel}
}
return ImageSupport{Reason: "Terminal companions need Kitty graphics or Sixel image support."}
}
type ImageDraw struct {
ID uint32
Animation *Animation
State State
Phase int
X int
Y int
OffsetX int
OffsetY int
Columns int
Rows int
HeightPixels int
}
type imageDrawKey struct {
protocol ImageProtocol
id uint32
animation *Animation
frame frameCacheKey
x int
y int
offsetX int
offsetY int
columns int
rows int
height int
}
type ImageRenderer struct {
mu sync.Mutex
support ImageSupport
cache string
desired *ImageDraw
last *imageDrawKey
}
func NewImageRenderer(support ImageSupport) *ImageRenderer {
return &ImageRenderer{support: support}
}
func NewImageRendererWithCache(support ImageSupport, cacheDir string) *ImageRenderer {
return &ImageRenderer{support: support, cache: strings.TrimSpace(cacheDir)}
}
func (r *ImageRenderer) Support() ImageSupport {
if r == nil {
return ImageSupport{Reason: "Terminal image rendering is unavailable."}
}
return r.support
}
func (r *ImageRenderer) Set(draw *ImageDraw) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if draw == nil {
r.desired = nil
return
}
copyValue := *draw
r.desired = &copyValue
}
func (r *ImageRenderer) Invalidate() {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.last = nil
}
func (r *ImageRenderer) Render(writer io.Writer) error {
if r == nil || writer == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if !r.support.Supported() {
return nil
}
var desiredKey *imageDrawKey
var pngBytes []byte
if r.desired != nil && r.desired.Animation != nil {
var frameKey frameCacheKey
var err error
pngBytes, frameKey, err = r.desired.Animation.framePNG(r.desired.State, r.desired.Phase)
if err != nil {
return err
}
desiredKey = &imageDrawKey{
protocol: r.support.Protocol, id: r.desired.ID, animation: r.desired.Animation, frame: frameKey,
x: r.desired.X, y: r.desired.Y, offsetX: r.desired.OffsetX, offsetY: r.desired.OffsetY,
columns: r.desired.Columns, rows: r.desired.Rows,
height: r.desired.HeightPixels,
}
}
if imageKeysEqual(r.last, desiredKey) {
return nil
}
if kittyImageContentEqual(r.last, desiredKey) {
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH%s\x1b[u", desiredKey.y+1, desiredKey.x+1, kittyPlace(desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
copyKey := *desiredKey
r.last = &copyKey
return nil
}
if r.last != nil {
if err := clearRenderedImage(writer, *r.last); err != nil {
return err
}
}
if desiredKey == nil {
r.last = nil
return nil
}
if _, err := fmt.Fprintf(writer, "\x1b[s\x1b[%d;%dH", desiredKey.y+1, desiredKey.x+1); err != nil {
return err
}
switch r.support.Protocol {
case ImageProtocolKitty:
if _, err := io.WriteString(writer, kittyTransmitPNG(pngBytes, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
case ImageProtocolKittyLocalFile:
path, err := cachePNGFrame(r.cache, pngBytes)
if err != nil {
return err
}
if _, err := io.WriteString(writer, kittyTransmitPNGFile(path, desiredKey.id, desiredKey.columns, desiredKey.rows, desiredKey.offsetX, desiredKey.offsetY)); err != nil {
return err
}
case ImageProtocolSixel:
frame := r.desired.Animation.Frame(r.desired.State, r.desired.Phase)
sixel, err := renderSixel(frame, r.desired.HeightPixels)
if err != nil {
return err
}
if _, err := writer.Write(sixel); err != nil {
return err
}
default:
return nil
}
if _, err := io.WriteString(writer, "\x1b[u"); err != nil {
return err
}
copyKey := *desiredKey
r.last = &copyKey
return nil
}
func (r *ImageRenderer) Clear(writer io.Writer) error {
r.Set(nil)
return r.Render(writer)
}
// DeleteImages removes known application-owned Kitty image placements even when
// this renderer no longer considers them active. This is intended for process
// shutdown and recovery from a previous interrupted render.
func (r *ImageRenderer) DeleteImages(writer io.Writer, ids ...uint32) error {
if r == nil || writer == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.desired = nil
r.last = nil
if r.support.Protocol != ImageProtocolKitty && r.support.Protocol != ImageProtocolKittyLocalFile {
return nil
}
for _, id := range ids {
if _, err := io.WriteString(writer, kittyDelete(id)); err != nil {
return err
}
}
return nil
}
func imageKeysEqual(left, right *imageDrawKey) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func kittyImageContentEqual(left, right *imageDrawKey) bool {
if left == nil || right == nil || left.protocol != right.protocol {
return false
}
if left.protocol != ImageProtocolKitty && left.protocol != ImageProtocolKittyLocalFile {
return false
}
leftValue, rightValue := *left, *right
leftValue.x, leftValue.y = 0, 0
leftValue.offsetX, leftValue.offsetY = 0, 0
rightValue.x, rightValue.y = 0, 0
rightValue.offsetX, rightValue.offsetY = 0, 0
return leftValue == rightValue
}
func kittyDelete(id uint32) string {
return fmt.Sprintf("\x1b_Ga=d,d=I,i=%d,q=2;\x1b\\", id)
}
func clearRenderedImage(writer io.Writer, key imageDrawKey) (err error) {
if key.protocol == ImageProtocolKitty || key.protocol == ImageProtocolKittyLocalFile {
_, err := io.WriteString(writer, kittyDelete(key.id))
return err
}
if key.protocol != ImageProtocolSixel {
return nil
}
// Sixel is the only protocol here that erases by PAINTING CHARACTERS. Kitty
// hands the terminal an id and the placement goes away; a sixel is already in
// the cell grid, so the only way to remove it is to write over it. That makes
// this the one path where the caller's SGR state matters, and it was not
// being normalised.
//
// The spaces below are ordinary characters, so they carry whatever background
// the TUI last set. Every erase therefore stamped a filled block in the
// ambient colour, and since a drag erases and repaints on every pixel, that
// block was continuously refreshed over the interface. It stayed invisible on
// a terminal whose ambient background matched its theme, which is why it
// never appeared on the Kitty-protocol terminals — ghostty, kitty, WezTerm,
// iTerm — that do not reach this code at all. Windows Terminal is the only
// mainstream terminal that lands here.
//
// DECSC/DECRC rather than CSI s/u: this now has to put back the SGR state as
// well as the cursor, and only DECSC saves attributes. Restoring them matters
// because the erase is emitted mid-frame, between styled writes the TUI has
// already started.
if _, err = io.WriteString(writer, "\x1b7\x1b[0m"); err != nil {
return err
}
defer func() {
if _, restoreErr := io.WriteString(writer, "\x1b8"); err == nil {
err = restoreErr
}
}()
for row := 0; row < key.rows; row++ {
if _, err = fmt.Fprintf(writer, "\x1b[%d;%dH%s", key.y+row+1, key.x+1, strings.Repeat(" ", key.columns)); err != nil {
return err
}
}
return nil
}
func kittyTransmitPNGFile(path string, id uint32, columns, rows, offsetX, offsetY int) string {
payload := base64.StdEncoding.EncodeToString([]byte(path))
return fmt.Sprintf("\x1b_Ga=T,t=f,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, payload)
}
func kittyPlace(id uint32, columns, rows, offsetX, offsetY int) string {
return fmt.Sprintf("\x1b_Ga=p,i=%d,p=%d,c=%d,r=%d%s,q=2,C=1;\x1b\\", id, id, columns, rows, kittyOffsetControl(offsetX, offsetY))
}
func kittyOffsetControl(offsetX, offsetY int) string {
if offsetX == 0 && offsetY == 0 {
return ""
}
return fmt.Sprintf(",X=%d,Y=%d", offsetX, offsetY)
}
func cachePNGFrame(cacheDir string, pngBytes []byte) (string, error) {
if cacheDir == "" {
return "", fmt.Errorf("terminal pet image cache is unavailable")
}
if err := os.MkdirAll(cacheDir, 0o700); err != nil {
return "", fmt.Errorf("create terminal pet image cache: %w", err)
}
digest := sha256.Sum256(pngBytes)
path := filepath.Join(cacheDir, fmt.Sprintf("%x.png", digest))
if _, err := os.Stat(path); err != nil {
if !os.IsNotExist(err) {
return "", fmt.Errorf("inspect cached terminal pet frame: %w", err)
}
if err := installtxn.WriteFileAtomically(path, pngBytes, 0o600); err != nil {
return "", fmt.Errorf("cache terminal pet frame: %w", err)
}
}
absolute, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("resolve cached terminal pet frame: %w", err)
}
return absolute, nil
}
func dottedVersionAtLeast(value string, major, minor, patch int) bool {
parts := strings.Split(strings.TrimSpace(value), ".")
if len(parts) == 0 || len(parts) > 3 {
return false
}
parsed := [3]int{}
for index, part := range parts {
digits := strings.TrimLeftFunc(part, func(value rune) bool { return value < '0' || value > '9' })
end := 0
for end < len(digits) && digits[end] >= '0' && digits[end] <= '9' {
end++
}
if end == 0 {
return false
}
number, err := strconv.Atoi(digits[:end])
if err != nil {
return false
}
parsed[index] = number
}
want := [3]int{major, minor, patch}
for index := range parsed {
if parsed[index] != want[index] {
return parsed[index] > want[index]
}
}
return true
}
func renderSixel(frame image.Image, heightPixels int) ([]byte, error) {
if frame == nil || frame.Bounds().Empty() {
return nil, fmt.Errorf("pet animation has no frame to encode")
}
if heightPixels < 1 {
heightPixels = 75
}
bounds := frame.Bounds()
widthPixels := max(1, bounds.Dx()*heightPixels/bounds.Dy())
scaled := image.NewNRGBA(image.Rect(0, 0, widthPixels, heightPixels))
xdraw.CatmullRom.Scale(scaled, scaled.Bounds(), frame, bounds, xdraw.Over, nil)
return encodeSixel(scaled)
}
func kittyTransmitPNG(pngBytes []byte, id uint32, columns, rows, offsetX, offsetY int) string {
payload := base64.StdEncoding.EncodeToString(pngBytes)
var output strings.Builder
for offset := 0; offset < len(payload); offset += kittyChunkSize {
end := min(offset+kittyChunkSize, len(payload))
more := 0
if end < len(payload) {
more = 1
}
if offset == 0 {
fmt.Fprintf(&output, "\x1b_Ga=T,t=d,f=100,c=%d,r=%d%s,q=2,C=1,i=%d,p=%d,m=%d;%s\x1b\\", columns, rows, kittyOffsetControl(offsetX, offsetY), id, id, more, payload[offset:end])
} else {
fmt.Fprintf(&output, "\x1b_Gm=%d;%s\x1b\\", more, payload[offset:end])
}
}
return output.String()
}
+173
View File
@@ -0,0 +1,173 @@
package terminalpet
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"os"
"strings"
"testing"
)
func TestDetectImageSupportUsesKittyGraphicsInGhostty(t *testing.T) {
env := map[string]string{
"TERM": "xterm-ghostty",
"TERM_PROGRAM": "ghostty",
}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolKitty {
t.Fatalf("Ghostty protocol = %v, want Kitty", support.Protocol)
}
}
func TestDetectImageSupportRejectsTmuxBeforeTerminalProtocol(t *testing.T) {
env := map[string]string{"TMUX": "/tmp/tmux-1000/default,1,0", "TERM": "xterm-ghostty"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Supported() || !strings.Contains(strings.ToLower(support.Reason), "tmux") {
t.Fatalf("tmux support = %#v, want unsupported tmux reason", support)
}
}
func TestDetectImageSupportUsesSixelInWindowsTerminal(t *testing.T) {
env := map[string]string{"WT_SESSION": "session-id", "TERM": "xterm-256color"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolSixel {
t.Fatalf("Windows Terminal protocol = %v, want Sixel", support.Protocol)
}
}
func TestDetectImageSupportUsesLocalKittyFilesInNewIterm(t *testing.T) {
env := map[string]string{"TERM_PROGRAM": "iTerm.app", "TERM_PROGRAM_VERSION": "3.6.1"}
support := DetectImageSupport(func(name string) string { return env[name] })
if support.Protocol != ImageProtocolKittyLocalFile {
t.Fatalf("iTerm2 protocol = %v, want Kitty local file", support.Protocol)
}
env["TERM_PROGRAM_VERSION"] = "3.5.9"
support = DetectImageSupport(func(name string) string { return env[name] })
if support.Supported() || !strings.Contains(support.Reason, "3.6") {
t.Fatalf("old iTerm2 support = %#v, want version error", support)
}
}
func TestImageRendererTransmitsPNGAndDeletesItWhenCleared(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, G: 80, B: 120, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
renderer.Set(&ImageDraw{
ID: 0xC0DF,
Animation: animation,
State: Idle,
Columns: 11,
Rows: 6,
X: 20,
Y: 10,
})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
for _, want := range []string{"\x1b[11;21H", "_Ga=T,t=d,f=100,c=11,r=6,q=2,C=1,i=49375,p=49375", "iVBOR"} {
if !strings.Contains(got, want) {
t.Fatalf("Kitty output does not contain %q: %q", want, got)
}
}
output.Reset()
renderer.Set(nil)
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
if got := output.String(); !strings.Contains(got, "_Ga=d,d=I,i=49375,q=2") {
t.Fatalf("clear output did not delete image: %q", got)
}
}
func TestImageRendererMovesExistingKittyPlacementWithoutRetransmitting(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 240, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolKitty})
draw := &ImageDraw{ID: 31, Animation: animation, State: Idle, Columns: 4, Rows: 3, X: 2, Y: 3}
renderer.Set(draw)
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
output.Reset()
draw.X, draw.Y = 18, 9
draw.OffsetX, draw.OffsetY = 3, 7
renderer.Set(draw)
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "\x1b[10;19H") || !strings.Contains(got, "_Ga=p,i=31,p=31,c=4,r=3,X=3,Y=7,q=2,C=1;") {
t.Fatalf("placement-only move missing: %q", got)
}
if strings.Contains(got, "a=T") || strings.Contains(got, "iVBOR") || strings.Contains(got, "a=d") {
t.Fatalf("placement-only move retransmitted or deleted image data: %q", got)
}
}
func TestImageRendererEmitsSixelForSixelTerminal(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 2, 2))
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRenderer(ImageSupport{Protocol: ImageProtocolSixel})
renderer.Set(&ImageDraw{ID: 9, Animation: animation, State: Idle, Columns: 4, Rows: 3, HeightPixels: 12})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
if !strings.Contains(got, "\x1bP9;1;0q") || !strings.HasSuffix(got, "\x1b\\\x1b[u") {
t.Fatalf("Sixel output is missing its image payload: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("Sixel renderer emitted Kitty graphics: %q", got)
}
}
func TestImageRendererUsesLocalPNGForIterm(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
frame.SetNRGBA(0, 0, color.NRGBA{G: 255, A: 255})
animation, err := ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := NewImageRendererWithCache(ImageSupport{Protocol: ImageProtocolKittyLocalFile}, t.TempDir())
renderer.Set(&ImageDraw{ID: 11, Animation: animation, State: Idle, Columns: 4, Rows: 3})
var output bytes.Buffer
if err := renderer.Render(&output); err != nil {
t.Fatal(err)
}
got := output.String()
prefix := "_Ga=T,t=f,f=100,c=4,r=3,q=2,C=1,i=11,p=11;"
start := strings.Index(got, prefix)
if start < 0 {
t.Fatalf("local-file command missing: %q", got)
}
payload := got[start+len(prefix):]
payload = strings.SplitN(payload, "\x1b\\", 2)[0]
pathBytes, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
t.Fatalf("decode local-file payload: %v", err)
}
if _, err := os.Stat(string(pathBytes)); err != nil {
t.Fatalf("cached PNG path is unavailable: %v", err)
}
}
+375
View File
@@ -0,0 +1,375 @@
package terminalpet
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"strings"
"sync"
"time"
)
const (
DisabledID = "disabled"
DefaultManifestURL = "https://petdex.dev/api/manifest/v2"
DefaultRankingURL = "https://petdex.dev/api/pets/search?sort=installed&limit=60&includeMeta=0"
TrustedAssetHost = "assets.petdex.dev"
previewFrameCount = 6
atlasColumns = 8
)
type State string
const (
Idle State = "idle"
MoveRight State = "running-right"
MoveLeft State = "running-left"
Waving State = "waving"
Jumping State = "jumping"
Running State = "running"
Waiting State = "waiting"
Review State = "review"
Failed State = "failed"
)
type Entry struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Kind string `json:"kind,omitempty"`
SubmittedBy string `json:"submittedBy,omitempty"`
SpritesheetURL string `json:"spritesheet"`
PetJSONURL string `json:"petJson,omitempty"`
AssetBase string `json:"assetBase,omitempty"`
SpriteVersion int `json:"spriteVersionNumber"`
Local bool `json:"-"`
}
func (e Entry) Label() string {
if name := strings.TrimSpace(e.DisplayName); name != "" {
return name
}
return e.Slug
}
type Animation struct {
frames map[State][]image.Image
durations map[State][]time.Duration
loopStarts map[State]int
clickAnimations []State
pngMu sync.Mutex
pngCache map[frameCacheKey][]byte
}
type atlasTrack struct {
row int
count int
durations []time.Duration
repeat int
loop bool
fallbackIdle bool
}
type frameCacheKey struct {
state State
index int
}
func (a *Animation) Frame(state State, phase int) image.Image {
frame, _ := a.frame(state, phase)
return frame
}
func (a *Animation) FrameDelay(state State, phase int) time.Duration {
if a == nil {
return 0
}
frames := a.frames[state]
if len(frames) == 0 {
state = Idle
frames = a.frames[state]
}
if len(frames) == 0 {
return 0
}
durations := a.durations[state]
if len(durations) != len(frames) {
return 0
}
return durations[a.frameIndex(state, phase, len(frames))]
}
// PrimaryDuration reports how long a non-idle action plays before it settles
// into its fallback loop. A zero duration means the state loops from its first
// frame or has no configured playback boundary.
func (a *Animation) PrimaryDuration(state State) time.Duration {
if a == nil {
return 0
}
loopStart, ok := a.loopStarts[state]
durations := a.durations[state]
if !ok || loopStart == 0 || loopStart > len(durations) || len(durations) == 0 {
return 0
}
if loopStart < 0 {
loopStart = len(durations)
}
var total time.Duration
for _, duration := range durations[:loopStart] {
total += duration
}
return total
}
// ClickDuration reports the duration of an authored click action.
// The boolean is false for ordinary activity states and built-in interactions.
func (a *Animation) ClickDuration(state State) (time.Duration, bool) {
if a == nil {
return 0, false
}
isClick := false
for _, candidate := range a.clickAnimations {
if candidate == state {
isClick = true
break
}
}
if !isClick {
return 0, false
}
return a.PrimaryDuration(state), true
}
func (a *Animation) ClickAnimation(index int) (State, bool) {
if a == nil || len(a.clickAnimations) == 0 {
return "", false
}
if index < 0 {
index = -index
}
return a.clickAnimations[index%len(a.clickAnimations)], true
}
func (a *Animation) setClickAnimations(names []string) {
if a == nil {
return
}
a.clickAnimations = a.clickAnimations[:0]
seen := make(map[State]bool, len(names))
for _, name := range names {
state := State(strings.ToLower(strings.TrimSpace(name)))
if state == "" || seen[state] || len(a.frames[state]) == 0 {
continue
}
seen[state] = true
a.clickAnimations = append(a.clickAnimations, state)
}
}
func (a *Animation) frame(state State, phase int) (image.Image, frameCacheKey) {
if a == nil {
return nil, frameCacheKey{}
}
frames := a.frames[state]
if len(frames) == 0 {
state = Idle
frames = a.frames[Idle]
}
if len(frames) == 0 {
return nil, frameCacheKey{}
}
index := a.frameIndex(state, phase, len(frames))
return frames[index], frameCacheKey{state: state, index: index}
}
func (a *Animation) frameIndex(state State, phase, frameCount int) int {
if frameCount < 1 {
return 0
}
if phase < 0 {
phase = -phase
}
loopStart, configured := a.loopStarts[state]
if !configured {
return phase % frameCount
}
if phase < frameCount {
return phase
}
if loopStart >= 0 && loopStart < frameCount {
return loopStart + (phase-loopStart)%(frameCount-loopStart)
}
return frameCount - 1
}
func (a *Animation) framePNG(state State, phase int) ([]byte, frameCacheKey, error) {
frame, key := a.frame(state, phase)
if frame == nil {
return nil, key, fmt.Errorf("pet animation has no frames")
}
a.pngMu.Lock()
defer a.pngMu.Unlock()
if cached := a.pngCache[key]; len(cached) > 0 {
return cached, key, nil
}
var encoded bytes.Buffer
if err := png.Encode(&encoded, frame); err != nil {
return nil, key, fmt.Errorf("encode pet frame: %w", err)
}
if a.pngCache == nil {
a.pngCache = make(map[frameCacheKey][]byte)
}
value := append([]byte(nil), encoded.Bytes()...)
a.pngCache[key] = value
return value, key, nil
}
func PreviewAnimation(sheet image.Image) (*Animation, error) {
if sheet == nil {
return nil, fmt.Errorf("preview image is empty")
}
bounds := sheet.Bounds()
if bounds.Dx() < previewFrameCount || bounds.Dx()%previewFrameCount != 0 || bounds.Dy() < 1 {
return nil, fmt.Errorf("preview must contain %d equal-width frames", previewFrameCount)
}
frames := splitRow(sheet, previewFrameCount, 0, bounds.Dy())
return &Animation{
frames: map[State][]image.Image{Idle: frames},
durations: map[State][]time.Duration{Idle: repeatedDurations(len(frames), 140*time.Millisecond)},
}, nil
}
func ThumbnailAnimation(imageValue image.Image) (*Animation, error) {
if imageValue == nil || imageValue.Bounds().Empty() {
return nil, fmt.Errorf("thumbnail image is empty")
}
return &Animation{frames: map[State][]image.Image{Idle: {imageValue}}}, nil
}
func AtlasAnimation(sheet image.Image, spriteVersion int) (*Animation, error) {
return atlasAnimation(sheet, spriteVersion, nil)
}
func atlasAnimation(sheet image.Image, spriteVersion int, overrides map[State]atlasTrack) (*Animation, error) {
if sheet == nil {
return nil, fmt.Errorf("spritesheet is empty")
}
rows := 9
if spriteVersion == 2 {
rows = 11
} else if spriteVersion != 0 && spriteVersion != 1 {
return nil, fmt.Errorf("unsupported sprite version %d", spriteVersion)
}
bounds := sheet.Bounds()
if bounds.Dx()%atlasColumns != 0 || bounds.Dy()%rows != 0 {
return nil, fmt.Errorf("spritesheet must be an 8x%d grid", rows)
}
frameWidth := bounds.Dx() / atlasColumns
frameHeight := bounds.Dy() / rows
if frameWidth < 1 || frameHeight < 1 || frameWidth*208 != frameHeight*192 {
return nil, fmt.Errorf("spritesheet frames must use the 192x208 aspect ratio")
}
stateRows := map[State]atlasTrack{
Idle: {row: 0, count: 6, durations: durations(1680, 660, 660, 840, 840, 1920), loop: true},
MoveRight: {row: 1, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
MoveLeft: {row: 2, count: 8, durations: finalFrameDurations(8, 120, 220), repeat: 3, fallbackIdle: true},
Waving: {row: 3, count: 4, durations: finalFrameDurations(4, 140, 280), repeat: 3, fallbackIdle: true},
Jumping: {row: 4, count: 5, durations: finalFrameDurations(5, 140, 280), repeat: 3, fallbackIdle: true},
Failed: {row: 5, count: 8, durations: finalFrameDurations(8, 140, 240), repeat: 3, fallbackIdle: true},
Waiting: {row: 6, count: 6, durations: finalFrameDurations(6, 150, 260), repeat: 3, fallbackIdle: true},
Running: {row: 7, count: 6, durations: finalFrameDurations(6, 120, 220), repeat: 3, fallbackIdle: true},
Review: {row: 8, count: 6, durations: finalFrameDurations(6, 150, 280), repeat: 3, fallbackIdle: true},
}
for state, override := range overrides {
stateRows[state] = override
}
rawFrames := make(map[State][]image.Image, len(stateRows))
for state, spec := range stateRows {
if spec.row < 0 || spec.row >= rows || spec.count < 1 || spec.count > atlasColumns {
return nil, fmt.Errorf("invalid %s animation geometry", state)
}
if len(spec.durations) == 0 {
spec.durations = repeatedDurations(spec.count, 180*time.Millisecond)
stateRows[state] = spec
} else if len(spec.durations) != spec.count {
return nil, fmt.Errorf("%s animation has %d durations for %d frames", state, len(spec.durations), spec.count)
}
rawFrames[state] = splitRow(sheet, atlasColumns, spec.row*frameHeight, frameHeight)[:spec.count]
}
idleFrames := rawFrames[Idle]
idleDurations := stateRows[Idle].durations
frames := make(map[State][]image.Image, len(stateRows))
frameDurations := make(map[State][]time.Duration, len(stateRows))
loopStarts := make(map[State]int, len(stateRows))
for state, spec := range stateRows {
repeat := max(1, spec.repeat)
for range repeat {
frames[state] = append(frames[state], rawFrames[state]...)
if len(spec.durations) == spec.count {
frameDurations[state] = append(frameDurations[state], spec.durations...)
}
}
switch {
case spec.fallbackIdle && state != Idle:
loopStarts[state] = len(frames[state])
frames[state] = append(frames[state], idleFrames...)
frameDurations[state] = append(frameDurations[state], idleDurations...)
case spec.loop:
loopStarts[state] = 0
default:
loopStarts[state] = -1
}
}
return &Animation{frames: frames, durations: frameDurations, loopStarts: loopStarts}, nil
}
func durations(milliseconds ...int) []time.Duration {
result := make([]time.Duration, len(milliseconds))
for index, value := range milliseconds {
result[index] = time.Duration(value) * time.Millisecond
}
return result
}
func repeatedDurations(count int, duration time.Duration) []time.Duration {
result := make([]time.Duration, count)
for index := range result {
result[index] = duration
}
return result
}
func finalFrameDurations(count, regularMilliseconds, finalMilliseconds int) []time.Duration {
result := repeatedDurations(count, time.Duration(regularMilliseconds)*time.Millisecond)
if len(result) > 0 {
result[len(result)-1] = time.Duration(finalMilliseconds) * time.Millisecond
}
return result
}
func splitRow(sheet image.Image, count, y, height int) []image.Image {
bounds := sheet.Bounds()
width := bounds.Dx() / count
frames := make([]image.Image, 0, count)
for index := 0; index < count; index++ {
frames = append(frames, cropImage{
source: sheet,
bounds: image.Rect(0, 0, width, height),
offset: image.Pt(bounds.Min.X+index*width, bounds.Min.Y+y),
})
}
return frames
}
type cropImage struct {
source image.Image
bounds image.Rectangle
offset image.Point
}
func (c cropImage) ColorModel() color.Model { return c.source.ColorModel() }
func (c cropImage) Bounds() image.Rectangle { return c.bounds }
func (c cropImage) At(x, y int) color.Color {
return c.source.At(x+c.offset.X, y+c.offset.Y)
}
+38
View File
@@ -0,0 +1,38 @@
package terminalpet
import (
"image"
"testing"
"time"
)
func TestClickDurationEndsOnceAnimationWithoutGenericHold(t *testing.T) {
action := State("custom-action")
animation := &Animation{
durations: map[State][]time.Duration{
action: {200 * time.Millisecond, 100 * time.Millisecond, 300 * time.Millisecond, 200 * time.Millisecond},
},
loopStarts: map[State]int{action: -1},
clickAnimations: []State{action},
}
if got := animation.PrimaryDuration(action); got != 800*time.Millisecond {
t.Fatalf("once-action duration = %s, want 800ms", got)
}
if got, ok := animation.ClickDuration(action); !ok || got != 800*time.Millisecond {
t.Fatalf("click duration = %s, %t; want 800ms, true", got, ok)
}
if got, ok := animation.ClickDuration(Running); ok || got != 0 {
t.Fatalf("ordinary activity duration = %s, %t; want 0, false", got, ok)
}
}
func TestAtlasAnimationRejectsMismatchedOverrideDurations(t *testing.T) {
sheet := image.NewNRGBA(image.Rect(0, 0, 192, 26*9))
_, err := atlasAnimation(sheet, 1, map[State]atlasTrack{
Idle: {row: 0, count: 2, durations: []time.Duration{100 * time.Millisecond}},
})
if err == nil {
t.Fatal("atlasAnimation accepted mismatched override durations")
}
}
+93
View File
@@ -0,0 +1,93 @@
package terminalpet
import (
"fmt"
"image"
"sort"
"strings"
)
const sixelBandHeight = 6
func encodeSixel(source image.Image) ([]byte, error) {
if source == nil || source.Bounds().Empty() {
return nil, fmt.Errorf("sixel image dimensions must be non-zero")
}
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
pixels := make([]int16, width*height)
for index := range pixels {
pixels[index] = -1
}
used := make(map[uint8]struct{})
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
red, green, blue, alpha := source.At(bounds.Min.X+x, bounds.Min.Y+y).RGBA()
if alpha>>8 < 128 {
continue
}
index := uint8((red>>13)<<5 | (green>>13)<<2 | blue>>14)
pixels[y*width+x] = int16(index)
used[index] = struct{}{}
}
}
colors := make([]int, 0, len(used))
for index := range used {
colors = append(colors, int(index))
}
sort.Ints(colors)
var output strings.Builder
output.WriteString("\x1bP9;1;0q")
fmt.Fprintf(&output, "\"1;1;%d;%d", width, height)
for _, index := range colors {
red := ((index >> 5) & 7) * 100 / 7
green := ((index >> 2) & 7) * 100 / 7
blue := (index & 3) * 100 / 3
fmt.Fprintf(&output, "#%d;2;%d;%d;%d", index, red, green, blue)
}
for bandTop := 0; bandTop < height; bandTop += sixelBandHeight {
active := activeSixelColors(pixels, width, height, bandTop, colors)
for colorPosition, colorIndex := range active {
fmt.Fprintf(&output, "#%d", colorIndex)
for x := 0; x < width; x++ {
mask := byte(0)
for bit := 0; bit < sixelBandHeight && bandTop+bit < height; bit++ {
if pixels[(bandTop+bit)*width+x] == int16(colorIndex) {
mask |= 1 << bit
}
}
output.WriteByte('?' + mask)
}
if colorPosition+1 < len(active) {
output.WriteByte('$')
}
}
if bandTop+sixelBandHeight < height {
if len(active) > 0 {
output.WriteByte('$')
}
output.WriteByte('-')
}
}
output.WriteString("\x1b\\")
return []byte(output.String()), nil
}
func activeSixelColors(pixels []int16, width, height, bandTop int, colors []int) []int {
activeSet := make(map[int16]struct{})
for y := bandTop; y < min(height, bandTop+sixelBandHeight); y++ {
for x := 0; x < width; x++ {
if color := pixels[y*width+x]; color >= 0 {
activeSet[color] = struct{}{}
}
}
}
active := make([]int, 0, len(activeSet))
for _, color := range colors {
if _, ok := activeSet[int16(color)]; ok {
active = append(active, color)
}
}
return active
}
+113
View File
@@ -0,0 +1,113 @@
package terminalpet
import (
"bytes"
"errors"
"fmt"
"strings"
"testing"
)
// A sixel erase paints characters, so it must not inherit the caller's colours.
//
// This is the black rectangle reported on Windows Terminal. The erase writes
// literal spaces over the cells the image occupied, and those spaces are drawn
// with whatever SGR was last set; without a reset every erase stamps a filled
// block in the ambient background. A drag erases and repaints on every pixel, so
// the block is continuously refreshed over the interface.
//
// It never showed on ghostty, kitty, WezTerm or iTerm because those resolve to
// the Kitty protocol and delete by image id, never reaching this path. Windows
// Terminal is the only mainstream terminal that does, which is why the platform
// looked at fault when the real cause is an untested code path.
func TestSixelEraseDoesNotPaintWithTheCallersColours(t *testing.T) {
var out bytes.Buffer
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
if err := clearRenderedImage(&out, key); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
got := out.String()
reset := strings.Index(got, "\x1b[0m")
if reset < 0 {
t.Fatalf("the erase never resets SGR, so it paints a block in the ambient background: %q", got)
}
// Before the first space, not merely present somewhere.
if firstSpace := strings.Index(got, strings.Repeat(" ", key.columns)); firstSpace >= 0 && reset > firstSpace {
t.Errorf("SGR is reset only after the first row is painted, so that row still carries the caller's colours: %q", got)
}
// DECSC/DECRC, because only those restore the attributes this now clobbers.
// CSI s/u save the cursor alone and would leave the TUI drawing in the reset
// state for the remainder of the frame.
if !strings.HasPrefix(got, "\x1b7") {
t.Errorf("erase must open with DECSC to save cursor AND attributes, got %q", got)
}
if !strings.HasSuffix(got, "\x1b8") {
t.Errorf("erase must close with DECRC to restore them, got %q", got)
}
}
// The erase still covers exactly the cells the image claimed, or the fix above
// would be satisfied by an erase that does nothing.
func TestSixelEraseStillCoversEveryClaimedCell(t *testing.T) {
var out bytes.Buffer
key := imageDrawKey{protocol: ImageProtocolSixel, x: 40, y: 2, columns: 6, rows: 3}
if err := clearRenderedImage(&out, key); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
got := out.String()
for row := 0; row < key.rows; row++ {
position := fmt.Sprintf("\x1b[%d;%dH", key.y+row+1, key.x+1)
erase := position + strings.Repeat(" ", key.columns)
if !strings.Contains(got, erase) {
t.Fatalf("erase does not position row %d at %q: %q", row, position, got)
}
}
if strings.Count(got, strings.Repeat(" ", key.columns)) != key.rows {
t.Errorf("erase covered %d rows of %d columns, want %d rows: %q",
strings.Count(got, strings.Repeat(" ", key.columns)), key.columns, key.rows, got)
}
}
func TestSixelEraseRestoresTerminalStateAfterRowWriteFailure(t *testing.T) {
rowErr := errors.New("row write failed")
writer := &failOnceWriter{failOnWrite: 2, err: rowErr}
key := imageDrawKey{protocol: ImageProtocolSixel, x: 4, y: 2, columns: 3, rows: 2}
err := clearRenderedImage(writer, key)
if !errors.Is(err, rowErr) {
t.Fatalf("clearRenderedImage error = %v, want primary row error", err)
}
if !strings.HasSuffix(writer.String(), "\x1b8") {
t.Fatalf("terminal state was not restored after row failure: %q", writer.String())
}
}
type failOnceWriter struct {
bytes.Buffer
writes int
failOnWrite int
err error
}
func (w *failOnceWriter) Write(value []byte) (int, error) {
w.writes++
if w.writes == w.failOnWrite {
return 0, w.err
}
return w.Buffer.Write(value)
}
// Kitty must NOT gain the character-painting erase: it deletes by image id, and
// writing spaces there would erase cells the terminal is about to redraw itself.
func TestKittyEraseStillDeletesByImageID(t *testing.T) {
for _, protocol := range []ImageProtocol{ImageProtocolKitty, ImageProtocolKittyLocalFile} {
var out bytes.Buffer
if err := clearRenderedImage(&out, imageDrawKey{protocol: protocol, id: 7, columns: 6, rows: 3}); err != nil {
t.Fatalf("clearRenderedImage: %v", err)
}
if got := out.String(); strings.Contains(got, " ") || !strings.Contains(got, "\x1b_G") {
t.Errorf("protocol %v should delete by id, not paint cells: %q", protocol, got)
}
}
}
+16 -4
View File
@@ -57,9 +57,10 @@ func pasteFromClipboardCmd() tea.Cmd {
// shared by the terminal bracketed-paste handler (tea.PasteMsg) and the
// right-click paste (clipboardReadMsg) so a bracketed paste and a right-click
// paste behave identically. Surfaces with no editable text field (a permission/
// spec prompt, the MCP manager, an open picker, the detailed transcript) swallow
// the paste. The session rename editor accepts text only; empty content is a
// no-op there rather than an image probe.
// spec prompt, the MCP manager, or the detailed transcript) swallow the paste.
// Searchable pickers consume pasted text through the same filtering path as
// typed characters. The session rename editor accepts text only; empty content
// is a no-op there rather than an image probe.
func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
// A paste is a deliberate action, same as a keypress or click — it means
// the user moved on to something else, so it disarms a stale Esc
@@ -101,7 +102,18 @@ func (m model) routePaste(content string) (tea.Model, tea.Cmd) {
if m.providerWizard != nil {
return m.handleProviderWizardPaste(content)
}
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil {
if m.picker != nil {
if m.modelPickerIsLoading() {
return m, nil
}
m.picker.appendQuery([]rune(sanitizeComposerInput(content)))
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
if m.transcriptDetailed || m.pendingSpecReview != nil || m.pendingPermission != nil || m.mcpAddWizard != nil || m.mcpManager != nil {
return m, nil
}
// A drag-dropped image/PDF arrives as a (backslash-escaped) file path. Attach
+9
View File
@@ -53,6 +53,7 @@ const (
commandGoal
commandVoice
commandSTTModel
commandPets
commandUnknown
)
@@ -353,6 +354,14 @@ var commandDefinitions = []commandDefinition{
description: "Show available commands.",
kind: commandHelp,
},
{
name: "/pets",
aliases: []string{"/pet"},
usage: "/pets [name|off]",
group: commandGroupMeta,
description: "Choose, preview, or hide a terminal companion.",
kind: commandPets,
},
{
name: "/doctor",
aliases: []string{"/health"},
+2 -2
View File
@@ -40,8 +40,8 @@ func (m model) moveModalSelection(delta int) (model, tea.Cmd, bool) {
if m.modelPickerIsLoading() {
return m, nil, true
}
m.pickerMoved(delta)
return m, nil, true
m, cmd := m.pickerMoved(delta)
return m, cmd, true
}
if m.suggestionsActive() {
m.moveSuggestion(delta)
+169 -10
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
@@ -15,6 +16,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
uv "github.com/charmbracelet/ultraviolet"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/agent"
@@ -33,6 +35,7 @@ import (
"github.com/Gitlawb/zero/internal/sessions"
"github.com/Gitlawb/zero/internal/skills"
"github.com/Gitlawb/zero/internal/streamjson"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/usage"
"github.com/Gitlawb/zero/internal/usercommands"
@@ -155,6 +158,48 @@ type model struct {
execProfileEffortTouched bool
execProfileSelfCorrectTouched bool
responseStyle string
petClient *terminalpet.Client
petRenderer *terminalpet.ImageRenderer
petEntries map[string]terminalpet.Entry
petID string
petName string
petAnimation *terminalpet.Animation
petPreview *terminalpet.Animation
petPreviewSlug string
petPreviewError string
petPreviewLoading bool
petPreviewSeq uint64
petPreviewCancel context.CancelFunc
petRequestedSlug string
petPhase int
petTickSeq uint64
petPlaybackState terminalpet.State
petClickAnimationIndex int
petOutcome terminalpet.State
petOutcomeAt time.Time
petLayoutRendering bool
petPositionSet bool
petPositionX int
petPositionY int
petDragActive bool
petDragMoved bool
petDragStartedDocked bool
petDragOffsetX int
petDragOffsetY int
petDragTargetX int
petDragTargetY int
petDragTargetOffsetX int
petDragTargetOffsetY int
petPositionOffsetX int
petPositionOffsetY int
petCellPixelWidth int
petCellPixelHeight int
petPixelDrag bool
petPixelAnchorSet bool
petDragOffsetPixelX int
petDragOffsetPixelY int
petDragState terminalpet.State
petLastClickAt time.Time
keyBindings keyBindings
themeMode themeMode // palette preference: auto (default), dark, light
hasDarkBg bool // last terminal background-detection result (auto mode)
@@ -928,6 +973,8 @@ func newModel(ctx context.Context, options Options) model {
permissionMode: permissionMode,
reasoningEffort: options.ReasoningEffort,
responseStyle: defaultedResponseStyle(options.ResponseStyle),
petEntries: map[string]terminalpet.Entry{},
petID: strings.TrimSpace(options.SavedPet),
keyBindings: resolvedKeyBindings,
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
hasDarkBg: true,
@@ -951,6 +998,22 @@ func newModel(ctx context.Context, options Options) model {
setupSave: options.Setup.Save,
dictation: newDictationController(options),
}
petRoot := userConfigDir
if strings.TrimSpace(options.UserConfigPath) != "" {
petRoot = filepath.Dir(options.UserConfigPath)
}
if strings.TrimSpace(petRoot) != "" {
m.petClient = terminalpet.NewClient(petRoot)
if m.petID != "" && m.petID != terminalpet.DisabledID {
if animation, loadErr := m.petClient.LoadInstalled(m.petID); loadErr == nil {
m.petAnimation = animation
m.petName = m.petID
if entry, entryErr := m.petClient.InstalledEntry(m.petID); entryErr == nil {
m.petName = entry.Label()
}
}
}
}
// Apply an explicit theme immediately; auto stays on the dark default until
// Init's terminal background probe resolves it (see Init / BackgroundColorMsg).
if m.themeMode != themeAuto {
@@ -1033,6 +1096,17 @@ func composerBlinkCmd() tea.Cmd {
func (m model) Init() tea.Cmd {
cmds := []tea.Cmd{textinput.Blink, composerBlinkCmd()}
if m.petAnimation != nil && !m.reducedMotion {
cmds = append(cmds, petTickCmd(m.petTickSeq, m.petFrameDelay()))
}
// Every image protocol wants this, not just the ones that support pixel
// dragging. Sixel needs it most: it erases itself by writing over cells, so
// without the pixels-per-cell figure it cannot know how many cells it
// covered, and falls back to constants describing the reserved area rather
// than the image.
if m.petCellMetricsWanted() {
cmds = append(cmds, tea.Raw(ansi.WindowOp(16)))
}
// Bubble Tea documents an initial WindowSizeMsg as delivered automatically
// on program start, so m.height/m.width are normally set before the first
// render. But that's the terminal proactively pushing a size — if it's
@@ -1234,6 +1308,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return next, cmd
}
switch msg := msg.(type) {
case uv.CellSizeEvent:
if msg.Width > 0 && msg.Height > 0 {
m.petCellPixelWidth = msg.Width
m.petCellPixelHeight = msg.Height
}
return m, nil
case peerMessageMsg:
admitted := m.canAcceptPeerMessage(msg.message)
if msg.admit != nil {
@@ -1396,6 +1476,19 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.KeyPressMsg:
if m.petDragActive {
pixelDrag := m.petPixelDrag
if !keyIs(msg, tea.KeyEsc) && !keyCtrl(msg, 'c') {
return m, nil
}
m.cancelPetDrag()
m.lastKeyTime = time.Time{}
m.burstCount = 0
if pixelDrag {
return m, petPixelMouseDisableCmd()
}
return m, nil
}
// Paste-detection timing trackers. MUST run before any early return
// so burst counting stays accurate regardless of which branch fires.
now := m.now()
@@ -1607,6 +1700,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// committed palette since Esc dismisses without choosing.
m.restoreCommittedTheme()
}
if m.picker.kind == pickerPet {
m.cancelPetPreview()
}
m.picker = nil
return m, nil
}
@@ -1835,6 +1931,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Editing the filter changes which row is highlighted; keep the
// theme preview in sync with it (no-op for other pickers).
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
// On an empty composer, Backspace removes the last attachment chip
@@ -2057,6 +2156,9 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// Filtering changes the highlighted row; keep the theme preview in
// sync with it (no-op for other pickers).
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
}
return m, nil
}
@@ -2085,12 +2187,22 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.BlurMsg:
var petMouseCmd tea.Cmd
if m.petDragActive {
pixelDrag := m.petPixelDrag
m.cancelPetDrag()
m.lastKeyTime = time.Time{}
m.burstCount = 0
if pixelDrag {
petMouseCmd = petPixelMouseDisableCmd()
}
}
m.terminalFocused = false
m.composerCursorVisible = false
if m.notifier != nil {
m.notifier.SetFocused(false)
}
return m, nil
return m, petMouseCmd
case toolCallStreamStartMsg:
if msg.runID != m.activeRunID {
return m, nil
@@ -2261,6 +2373,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case tea.WindowSizeMsg:
m.resizeFreePetPosition(m.width, m.height, msg.Width, msg.Height)
m.width = msg.Width
m.height = msg.Height
// A resize re-wraps content at a new width, shifting every row's bodyY;
@@ -2419,6 +2532,12 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
if msg.err != nil {
m.petOutcome = terminalpet.Failed
} else {
m.petOutcome = terminalpet.Review
}
m.petOutcomeAt = m.now()
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// A newline-triggered redraw deferred by the stream-clear throttle
@@ -2807,6 +2926,34 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applySetupOAuthDeviceCode(msg)
case modelPickerModelsDiscoveredMsg:
return m.applyModelPickerModelsDiscovered(msg), nil
case petCatalogLoadedMsg:
return m.applyPetCatalog(msg)
case petPreviewDebounceMsg:
return m.startPetPreview(msg)
case petPreviewLoadedMsg:
m = m.applyPetPreview(msg)
if m.petPreview != nil && m.petAnimation == nil && !m.reducedMotion {
m.petTickSeq++
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
}
return m, nil
case petInstalledMsg:
return m.applyPetInstall(msg)
case petTickMsg:
if msg.seq != m.petTickSeq {
return m, nil
}
if (m.petAnimation == nil && (m.picker == nil || m.petPreview == nil)) || m.reducedMotion {
return m, nil
}
_, state := m.petPlayback()
if state != m.petPlaybackState {
m.petPlaybackState = state
m.petPhase = 0
} else {
m.petPhase++
}
return m, petTickCmd(m.petTickSeq, m.petFrameDelay())
case ollamaContextWindowDiscoveredMsg:
if msg.err == nil && msg.contextWindow > 0 {
if m.ollamaContextWindowByModel == nil {
@@ -2836,6 +2983,9 @@ func (m model) View() tea.View {
} else {
content = m.detailedTranscriptView()
}
if m.petRenderer != nil {
m.petRenderer.Set(m.petImageDraw(content))
}
view := tea.NewView(content)
view.AltScreen = m.altScreen
@@ -2908,6 +3058,9 @@ func (m model) transcriptView() string {
if m.sidebarActive() && !m.subchat.active {
return m.twoColumnTranscriptView()
}
if m.petLayoutActive() {
return m.floatingPetTranscriptView()
}
width := chatWidth(m.width)
@@ -3012,7 +3165,6 @@ func (m model) twoColumnTranscriptView() string {
header := m.pinnedTitleBar(width)
chatBlock := viewLines(m.scrollableTranscriptItemsView(header, bodyItems, footer, width, overlayForViewport))
sidebar := m.renderContextSidebar(sidebarW, len(chatBlock))
rows := joinColumns(chatBlock, sidebar, chatW, sidebarW)
return strings.Join(rows, "\n")
@@ -3040,7 +3192,7 @@ func (m model) footerView(width int) string {
if m.renamePrompt != nil {
footer.WriteString(m.sessionRenamePromptView(width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// While an ask-user questionnaire is active it REPLACES the composer box (the
@@ -3049,7 +3201,7 @@ func (m model) footerView(width int) string {
if m.pendingAskUser != nil {
footer.WriteString(renderAskUserQuestionnaire(*m.pendingAskUser, m.input.Value(), width))
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// A focused permission prompt owns the keyboard: its options (and the feedback
@@ -3059,7 +3211,7 @@ func (m model) footerView(width int) string {
// input from echoing in two places once "tell Zero what to do differently"
// opens the on-card feedback field.
if m.pendingPermission != nil {
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
// Pinned plan panel: sits directly above the composer so it stays visible
@@ -3100,7 +3252,7 @@ func (m model) footerView(width int) string {
footer.WriteString(hint)
}
footer.WriteString("\n")
footer.WriteString(m.statusLine(width))
footer.WriteString(m.footerStatusLine(width))
return footer.String()
}
@@ -4124,23 +4276,26 @@ func (m model) composerBox(width int) string {
if width < 8 {
return fitStyledLine(m.composerLine(width), width)
}
innerWidth := maxInt(1, width-4)
reserved := m.petComposerReservedColumns(width)
boxWidth := width - reserved
innerWidth := maxInt(1, boxWidth-4)
content := m.composerLine(innerWidth)
lines := strings.Split(content, "\n")
rightPad := strings.Repeat(" ", reserved)
rendered := make([]string, 0, len(lines)+3)
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", width-2)+"╮"))
rendered = append(rendered, zeroTheme.lineStrong.Render("╭"+strings.Repeat("─", boxWidth-2)+"╮")+rightPad)
// Attachment chips ([Image #1] …) render INSIDE the box, above the input line,
// instead of as a separate row above the box.
if chips := renderAttachmentChips(m.pendingImageLabels, m.pendingDocuments); chips != "" {
fitted := fitStyledLine(zeroTheme.muted.Render(chips), innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
}
for _, line := range lines {
fitted := fitStyledLine(line, innerWidth)
pad := strings.Repeat(" ", maxInt(0, innerWidth-lipgloss.Width(fitted)))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │"))
rendered = append(rendered, zeroTheme.lineStrong.Render("│ ")+fitted+pad+zeroTheme.lineStrong.Render(" │")+rightPad)
}
rendered = append(rendered, m.composerDividerLine(width))
return strings.Join(rendered, "\n")
@@ -4347,6 +4502,8 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
}
case pickerSTTDownload:
return m.handleSTTDownloadSelection(item.Value)
case pickerPet:
return m.installPet(item.Value)
case pickerTheme:
// The hovered palette is already live from the preview; handleThemeCommand
// records the choice (m.themeMode) and re-applies it, and reports the switch.
@@ -4503,6 +4660,8 @@ func (m model) dispatchCommand(command parsedCommand) (tea.Model, tea.Cmd) {
return m.handleLoopCommand(command.text)
case commandGoal:
return m.handleGoalCommand(command.text)
case commandPets:
return m.handlePetsCommand(command.text)
case commandExit:
// Closing the session stops its foreground loops mid-task; warn once so a
// token-spending loop isn't ended by reflex.
+8 -4
View File
@@ -151,6 +151,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if mouseRightPress(msg) {
return m, pasteFromClipboardCmd()
}
if next, cmd, handled := m.handlePetMouse(msg); handled {
return next, cmd
}
if mouseLeftPress(msg) {
switch {
case m.providerWizard != nil:
@@ -187,6 +190,9 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return m.choosePicker()
}
m.lastMouseSelection = target
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
case m.suggestionsActive():
@@ -233,8 +239,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(-1)
return m, nil
return m.pickerMoved(-1)
}
if m.suggestionsActive() {
m.moveSuggestion(-1)
@@ -265,8 +270,7 @@ func (m model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if m.modelPickerIsLoading() {
return m, nil
}
m.pickerMoved(1)
return m, nil
return m.pickerMoved(1)
}
if m.suggestionsActive() {
m.moveSuggestion(1)
+18 -4
View File
@@ -14,15 +14,29 @@ func mouseEventFilter() func(tea.Model, tea.Msg) tea.Msg {
func newMouseEventFilter(now func() time.Time, minInterval time.Duration) func(tea.Model, tea.Msg) tea.Msg {
var last time.Time
return func(_ tea.Model, msg tea.Msg) tea.Msg {
return func(current tea.Model, msg tea.Msg) tea.Msg {
switch msg.(type) {
case tea.MouseWheelMsg, tea.MouseMotionMsg:
current := now()
if !last.IsZero() && current.Sub(last) < minInterval {
if _, motion := msg.(tea.MouseMotionMsg); motion && petDragActive(current) {
return msg
}
timestamp := now()
if !last.IsZero() && timestamp.Sub(last) < minInterval {
return nil
}
last = current
last = timestamp
}
return msg
}
}
func petDragActive(current tea.Model) bool {
switch current := current.(type) {
case model:
return current.petDragActive
case *model:
return current != nil && current.petDragActive
default:
return false
}
}
+19
View File
@@ -51,3 +51,22 @@ func TestMouseEventFilterDoesNotThrottleKeyboard(t *testing.T) {
t.Fatal("keyboard events should not touch the mouse throttle clock")
}
}
func TestMouseEventFilterDoesNotThrottlePetDrag(t *testing.T) {
clockReads := 0
filter := newMouseEventFilter(func() time.Time {
clockReads++
return time.Unix(0, 0)
}, mouseEventThrottleInterval)
m := model{petDragActive: true}
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 1, Y: 1})); got == nil {
t.Fatal("first drag event should pass through")
}
if got := filter(m, tea.MouseMotionMsg(tea.Mouse{Button: tea.MouseLeft, X: 2, Y: 1})); got == nil {
t.Fatal("every pet-drag event should pass through for zero-gap tracking")
}
if clockReads != 0 {
t.Fatalf("pet drag should bypass the throttle clock, read it %d times", clockReads)
}
}
+4 -1
View File
@@ -72,7 +72,10 @@ type Options struct {
// SavedTheme is the theme persisted in user config (Preferences.Theme). Applied
// at startup below --theme and ZERO_THEME, so a /theme choice survives restart.
SavedTheme string
UserAgent string
// SavedPet is the persisted terminal companion id. Empty means no pet has
// been selected yet; "disabled" records an explicit opt-out.
SavedPet string
UserAgent string
// Notify configures completion / awaiting-input notifications.
Notify config.NotifyConfig
+188
View File
@@ -0,0 +1,188 @@
package tui
import (
"bytes"
"io"
"sync"
"github.com/charmbracelet/x/ansi"
"github.com/Gitlawb/zero/internal/terminalpet"
)
const (
terminalSyncStart = "\x1b[?2026h"
terminalSyncEnd = "\x1b[?2026l"
)
type terminalOutputFile interface {
io.ReadWriteCloser
Fd() uintptr
}
type petImageOutput struct {
output terminalOutputFile
renderer *terminalpet.ImageRenderer
renderDisabled bool
mu sync.Mutex
}
func newPetImageOutput(output terminalOutputFile, renderer *terminalpet.ImageRenderer) *petImageOutput {
return &petImageOutput{output: output, renderer: renderer}
}
func (o *petImageOutput) Read(value []byte) (int, error) {
return o.output.Read(value)
}
func (o *petImageOutput) Write(value []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
leavingAltScreen := bytes.Contains(value, []byte("\x1b[?1049l"))
kittyImage := o.renderer.Support().Protocol == terminalpet.ImageProtocolKitty ||
o.renderer.Support().Protocol == terminalpet.ImageProtocolKittyLocalFile
if leavingAltScreen && !kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return 0, err
}
}
if leavingAltScreen {
written, err := o.output.Write(value)
if err != nil {
return written, err
}
if kittyImage {
if err := o.writeImageUpdate(o.renderer.Clear); err != nil {
return written, err
}
}
return written, nil
}
if bytes.Contains(value, []byte(ansi.EraseEntireScreen)) ||
bytes.Contains(value, []byte(ansi.SetModeAltScreenSaveCursor)) {
o.renderer.Invalidate()
}
var imageUpdate bytes.Buffer
if !o.renderDisabled {
if err := o.renderer.Render(&imageUpdate); err != nil {
// Companions are decorative. Disable them for this output session after
// a renderer failure instead of terminating the interactive shell.
o.renderDisabled = true
imageUpdate.Reset()
}
}
if imageUpdate.Len() == 0 {
return writeChecked(o.output, value)
}
// Bubble Tea encloses supported terminal frames in synchronized-output
// markers. Keep the pet placement inside that same transaction so the
// terminal never presents the text frame and image movement separately.
syncStart := bytes.LastIndex(value, []byte(terminalSyncStart))
syncEnd := bytes.LastIndex(value, []byte(terminalSyncEnd))
if syncEnd >= 0 && syncEnd > syncStart {
var frame bytes.Buffer
frame.Grow(len(value) + imageUpdate.Len())
frame.Write(value[:syncEnd])
frame.Write(imageUpdate.Bytes())
frame.Write(value[syncEnd:])
written, err := o.output.Write(frame.Bytes())
consumed := originalBytesWritten(written, syncEnd, imageUpdate.Len(), len(value))
if err == nil && written != frame.Len() {
err = io.ErrShortWrite
}
if err != nil {
return consumed, err
}
return len(value), nil
}
if syncStart > syncEnd {
written, writeErr := writeChecked(o.output, value)
if writeErr != nil {
return written, writeErr
}
_, imageErr := writeChecked(o.output, imageUpdate.Bytes())
return written, imageErr
}
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
return 0, err
}
written, writeErr := writeChecked(o.output, value)
if writeErr == nil {
_, writeErr = writeChecked(o.output, imageUpdate.Bytes())
}
_, endErr := io.WriteString(o.output, terminalSyncEnd)
if writeErr != nil {
return written, writeErr
}
return written, endErr
}
func writeChecked(writer io.Writer, value []byte) (int, error) {
written, err := writer.Write(value)
if err == nil && written != len(value) {
err = io.ErrShortWrite
}
return written, err
}
// originalBytesWritten maps progress through an expanded synchronized frame
// back to bytes consumed from the caller's original buffer. Bytes belonging
// to the injected image update must never be reported as caller bytes.
func originalBytesWritten(written, prefixLength, injectedLength, originalLength int) int {
if written <= 0 {
return 0
}
if written <= prefixLength {
return written
}
consumed := prefixLength
suffixWritten := written - prefixLength - injectedLength
if suffixWritten > 0 {
consumed += min(suffixWritten, originalLength-prefixLength)
}
return min(consumed, originalLength)
}
func (o *petImageOutput) writeImageUpdate(render func(io.Writer) error) error {
var update bytes.Buffer
if err := render(&update); err != nil {
return err
}
if update.Len() == 0 {
return nil
}
if _, err := io.WriteString(o.output, terminalSyncStart); err != nil {
return err
}
_, writeErr := o.output.Write(update.Bytes())
_, endErr := io.WriteString(o.output, terminalSyncEnd)
if writeErr != nil {
return writeErr
}
return endErr
}
func (o *petImageOutput) Close() error {
return nil
}
func (o *petImageOutput) Fd() uintptr {
return o.output.Fd()
}
func (o *petImageOutput) clearImage() error {
o.mu.Lock()
defer o.mu.Unlock()
return o.writeImageUpdate(func(writer io.Writer) error {
if _, err := io.WriteString(writer, ansi.ResetModeMouseExtSgrPixel); err != nil {
return err
}
if err := o.renderer.Clear(writer); err != nil {
return err
}
return o.renderer.DeleteImages(writer, petAmbientImageID, petPreviewImageID)
})
}
+492
View File
@@ -0,0 +1,492 @@
package tui
import (
"errors"
"fmt"
"image"
"image/color"
"io"
"os"
"strings"
"sync"
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
"github.com/charmbracelet/x/ansi"
)
type shortTerminalOutput struct {
limit int
err error
}
func (o *shortTerminalOutput) Read([]byte) (int, error) { return 0, io.EOF }
func (o *shortTerminalOutput) Close() error { return nil }
func (o *shortTerminalOutput) Fd() uintptr { return 0 }
func (o *shortTerminalOutput) Write(value []byte) (int, error) {
return min(o.limit, len(value)), o.err
}
func TestPetImageOutputAppendsScheduledImageAfterFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
frame.SetNRGBA(0, 0, color.NRGBA{R: 255, A: 255})
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("bubbletea-frame")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if !strings.HasPrefix(got, terminalSyncStart+"bubbletea-frame") || !strings.Contains(got, "_Ga=T,t=d,f=100,c=4,r=3") {
t.Fatalf("output did not append image after frame: %q", got)
}
syncStart := strings.Index(got, "\x1b[?2026h")
frameAt := strings.Index(got, "bubbletea-frame")
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.LastIndex(got, "\x1b[?2026l")
if syncStart < 0 || frameAt < syncStart || imageAt < frameAt || syncEnd < imageAt {
t.Fatalf("frame and image update were not synchronized together: %q", got)
}
}
func TestPetImageOutputKeepsImageInsideBubbleTeaSynchronizedFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
if _, err := output.Write([]byte(bubbleTeaFrame)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if count := strings.Count(got, terminalSyncStart); count != 1 {
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
}
if count := strings.Count(got, terminalSyncEnd); count != 1 {
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
}
frameAt := strings.Index(got, "streamed-text-frame")
imageAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.Index(got, terminalSyncEnd)
if frameAt < 0 || imageAt < frameAt || syncEnd < imageAt {
t.Fatalf("pet update was presented outside the Bubble Tea frame: %q", got)
}
}
func TestPetImageOutputMapsPartialSynchronizedWriteToOriginalBytes(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
wantErr := errors.New("partial terminal write")
sink := &shortTerminalOutput{limit: prefixLength + 3, err: wantErr}
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
if !errors.Is(err, wantErr) {
t.Fatalf("Write error = %v, want %v", err, wantErr)
}
if written != prefixLength {
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
}
}
func TestPetImageOutputReportsNilErrorShortWrite(t *testing.T) {
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
bubbleTeaFrame := terminalSyncStart + "streamed-text-frame" + terminalSyncEnd
prefixLength := strings.Index(bubbleTeaFrame, terminalSyncEnd)
sink := &shortTerminalOutput{limit: prefixLength + 3}
written, err := newPetImageOutput(sink, renderer).Write([]byte(bubbleTeaFrame))
if !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("Write error = %v, want %v", err, io.ErrShortWrite)
}
if written != prefixLength {
t.Fatalf("Write reported %d original bytes, want %d", written, prefixLength)
}
}
func TestPetImageOutputKeepsDraggedPlacementInsideStreamingFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
renderer.Set(&draw)
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
draw.X = 8
draw.Y = 6
renderer.Set(&draw)
if _, err := output.Write([]byte(terminalSyncStart + "streamed-delta" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
if count := strings.Count(got, terminalSyncStart); count != 1 {
t.Fatalf("got %d synchronized-output starts, want 1: %q", count, got)
}
if count := strings.Count(got, terminalSyncEnd); count != 1 {
t.Fatalf("got %d synchronized-output ends, want 1: %q", count, got)
}
textAt := strings.Index(got, "streamed-delta")
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
syncEnd := strings.Index(got, terminalSyncEnd)
if textAt < 0 || moveAt < textAt || syncEnd < moveAt {
t.Fatalf("dragged pet placement was presented outside the streaming frame: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("dragging retransmitted image data instead of moving its placement: %q", got)
}
}
func TestPetImageOutputFlushesMovedPlacementWithoutTextChange(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
draw := terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3}
renderer.Set(&draw)
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
draw.X = 8
draw.Y = 6
renderer.Set(&draw)
if _, err := output.Write([]byte(terminalSyncStart + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
moveAt := strings.Index(got, "\x1b[7;9H\x1b_Ga=p")
syncEnd := strings.Index(got, terminalSyncEnd)
if moveAt < 0 || syncEnd < moveAt {
t.Fatalf("idle drag flush did not place the pet inside its empty frame: %q", got)
}
if strings.Contains(got, "_Ga=T") {
t.Fatalf("idle drag flush retransmitted image data: %q", got)
}
}
func TestPetImageOutputRetransmitsAfterFullScreenClear(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte(terminalSyncStart + "initial-frame" + terminalSyncEnd)); err != nil {
t.Fatal(err)
}
start, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
clearFrame := terminalSyncStart + ansi.EraseEntireScreen + "resized-frame" + terminalSyncEnd
if _, err := output.Write([]byte(clearFrame)); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data[start:])
clearAt := strings.Index(got, ansi.EraseEntireScreen)
transmitAt := strings.Index(got, "_Ga=T,t=d,f=100,c=4,r=3")
syncEnd := strings.Index(got, terminalSyncEnd)
if clearAt < 0 || transmitAt < clearAt || syncEnd < transmitAt {
t.Fatalf("pet was not restored inside the full-screen redraw: %q", got)
}
}
func TestPetImageOutputClearsKittyImageAfterLeavingAltScreen(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
deleteAt := strings.LastIndex(got, "_Ga=d,d=I,i=7,q=2")
exitAt := strings.LastIndex(got, "\x1b[?1049l")
if deleteAt < 0 || exitAt < 0 || deleteAt < exitAt {
t.Fatalf("Kitty image was not cleared after alt-screen exit: %q", got)
}
syncStart := strings.LastIndex(got[:deleteAt], "\x1b[?2026h")
syncEnd := strings.Index(got[deleteAt:], "\x1b[?2026l")
if syncStart < exitAt || syncEnd < 0 {
t.Fatalf("Kitty image cleanup was not synchronized after alt-screen exit: %q", got)
}
}
func TestPetImageOutputFinalCleanupRepeatsAllKittyDeletes(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
cleanupStart, err := file.Seek(0, io.SeekCurrent)
if err != nil {
t.Fatal(err)
}
if err := output.clearImage(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
cleanup := string(data[cleanupStart:])
if !strings.Contains(cleanup, ansi.ResetModeMouseExtSgrPixel) {
t.Fatalf("final cleanup did not reset pixel mouse mode: %q", cleanup)
}
for _, id := range []uint32{petAmbientImageID, petPreviewImageID} {
want := fmt.Sprintf("_Ga=d,d=I,i=%d,q=2", id)
if !strings.Contains(cleanup, want) {
t.Errorf("final cleanup did not repeat Kitty delete for image %d: %q", id, cleanup)
}
}
}
func TestPetImageOutputClearsSixelBeforeLeavingAltScreen(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
frame := image.NewNRGBA(image.Rect(0, 0, 1, 1))
animation, err := terminalpet.ThumbnailAnimation(frame)
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, X: 2, Y: 3, Columns: 4, Rows: 3, HeightPixels: 12})
output := newPetImageOutput(file, renderer)
if _, err := output.Write([]byte("frame")); err != nil {
t.Fatal(err)
}
if _, err := output.Write([]byte("\x1b[?1049l")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
clearAt := strings.LastIndex(got, "\x1b[4;3H ")
exitAt := strings.LastIndex(got, "\x1b[?1049l")
if clearAt < 0 || exitAt < 0 || clearAt > exitAt {
t.Fatalf("Sixel cell area was not cleared before alt-screen exit: %q", got)
}
}
func TestPetImageOutputDoesNotCloseAnExistingSynchronizedFrame(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
if _, err := newPetImageOutput(file, renderer).Write([]byte(terminalSyncStart + "partial-frame")); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
got := string(data)
if strings.Count(got, terminalSyncStart) != 1 || strings.Contains(got, terminalSyncEnd) {
t.Fatalf("pet output changed caller-owned synchronized frame boundaries: %q", got)
}
if strings.Index(got, "_Ga=T") < strings.Index(got, "partial-frame") {
t.Fatalf("pet image was not appended to the open frame: %q", got)
}
}
func TestPetImageOutputReportsUnsynchronizedShortWrite(t *testing.T) {
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
sink := &shortTerminalOutput{limit: len(terminalSyncStart)}
written, err := newPetImageOutput(sink, renderer).Write([]byte("long-frame-payload"))
if !errors.Is(err, io.ErrShortWrite) || written != len(terminalSyncStart) {
t.Fatalf("Write() = %d, %v; want %d, %v", written, err, len(terminalSyncStart), io.ErrShortWrite)
}
}
func TestPetImageOutputKeepsTextAliveAfterRendererFailure(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKittyLocalFile})
renderer.Set(&terminalpet.ImageDraw{ID: 7, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
for _, frame := range []string{"first-frame", "second-frame"} {
if written, err := output.Write([]byte(frame)); err != nil || written != len(frame) {
t.Fatalf("Write(%q) = %d, %v", frame, written, err)
}
}
data, err := os.ReadFile(file.Name())
if err != nil {
t.Fatal(err)
}
if got := string(data); got != "first-framesecond-frame" {
t.Fatalf("text output after renderer failure = %q", got)
}
}
func TestPetImageOutputSerializesWriteAndCleanup(t *testing.T) {
file, err := os.CreateTemp(t.TempDir(), "pet-output-*")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = file.Close() })
animation, err := terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 1, 1)))
if err != nil {
t.Fatal(err)
}
renderer := terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty})
renderer.Set(&terminalpet.ImageDraw{ID: petAmbientImageID, Animation: animation, State: terminalpet.Idle, Columns: 4, Rows: 3})
output := newPetImageOutput(file, renderer)
var wait sync.WaitGroup
errorsSeen := make(chan error, 9)
for range 8 {
wait.Add(1)
go func() {
defer wait.Done()
_, err := output.Write([]byte("frame"))
errorsSeen <- err
}()
}
wait.Add(1)
go func() {
defer wait.Done()
errorsSeen <- output.clearImage()
}()
wait.Wait()
close(errorsSeen)
for err := range errorsSeen {
if err != nil {
t.Fatal(err)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package tui
import (
"testing"
"github.com/Gitlawb/zero/internal/terminalpet"
)
// The erase rectangle has to describe what was PAINTED, not the reserved area.
//
// Measured on Windows Terminal 1.24, which answers CSI 16 t with a 20x10 cell.
// A companion is rendered at preferredHeight (75px), so it covers
// ceil(75/20) = 4 rows, while petImageRows is 5. The erase used the constant and
// blanked a fifth row of live interface every time the companion moved, which is
// the broken border and the eaten characters reported on Windows.
//
// Sixel is the only protocol that erases by writing over cells, so it is the
// only one that needs this; the Kitty case below pins that it is left alone.
func TestSixelEraseFootprintMatchesWhatWasPainted(t *testing.T) {
m := model{
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}),
petCellPixelWidth: 10,
petCellPixelHeight: 20,
}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if rows != 4 {
t.Errorf("rows = %d, want 4 for a 75px sprite in a 20px cell; erasing %d would blank live interface", rows, petImageRows)
}
if rows > petImageRows || columns > petImageColumns {
t.Errorf("footprint %dx%d exceeds the reserved %dx%d area", columns, rows, petImageColumns, petImageRows)
}
}
// Kitty passes Columns/Rows to the terminal as the placement REQUEST and the
// terminal owns the region, so the constants are correct there and must not be
// recomputed.
func TestKittyKeepsTheRequestedPlacementSize(t *testing.T) {
m := model{
petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}),
petCellPixelWidth: 10,
petCellPixelHeight: 20,
}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if columns != petImageColumns || rows != petImageRows {
t.Errorf("Kitty placement = %dx%d, want the requested %dx%d", columns, rows, petImageColumns, petImageRows)
}
}
// Without metrics there is nothing to compute from, so it must fall back to the
// constants rather than to zero, which would erase nothing and leave the
// companion smeared across the screen.
func TestFootprintFallsBackToTheReservedAreaWithoutMetrics(t *testing.T) {
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel})}
columns, rows := m.petImageCells(nil, terminalpet.Idle, 0, 75)
if columns != petImageColumns || rows != petImageRows {
t.Errorf("fallback = %dx%d, want the reserved %dx%d", columns, rows, petImageColumns, petImageRows)
}
}
// The request has to be sent for a sixel terminal, or the metrics never arrive
// and every computation above silently uses the fallback. This is the actual
// defect: the reply was never asked for, not unsupported.
func TestCellMetricsAreRequestedForEveryImageProtocol(t *testing.T) {
for _, protocol := range []terminalpet.ImageProtocol{
terminalpet.ImageProtocolSixel,
terminalpet.ImageProtocolKitty,
terminalpet.ImageProtocolKittyLocalFile,
} {
m := model{petRenderer: terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: protocol})}
if !m.petCellMetricsWanted() {
t.Errorf("protocol %v does not ask the terminal for its cell size, so the reply never arrives", protocol)
}
}
}
+1065
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -2
View File
@@ -27,6 +27,7 @@ const (
pickerSkill
pickerSTTModel
pickerSTTDownload
pickerPet
)
// pickerItem is one selectable row: Label is shown, Value is passed to the
@@ -1029,10 +1030,14 @@ func (m model) newThemePicker() *commandPicker {
// repaints the UI in the hovered palette. Safe to call with no picker open. Callers
// mutate through m.picker (a pointer) and the global theme, so the value receiver
// is fine.
func (m model) pickerMoved(delta int) {
func (m model) pickerMoved(delta int) (model, tea.Cmd) {
if m.picker == nil {
return
return m, nil
}
m.picker.move(delta)
m.previewSelectedTheme()
if m.picker.kind == pickerPet {
return m.schedulePetPreview()
}
return m, nil
}
+38 -1
View File
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
@@ -11,6 +13,7 @@ import (
"github.com/charmbracelet/x/term"
"github.com/Gitlawb/zero/internal/peermsg"
"github.com/Gitlawb/zero/internal/terminalpet"
)
// Run starts the Zero Bubble Tea shell and returns a process-style exit code.
@@ -41,11 +44,13 @@ func Run(ctx context.Context, options Options) int {
// text first, keeping order intact.
options.RuntimeMessageSink = newTextCoalescer(forward).send
options.AltScreen = useAltScreen(options)
petRenderer := terminalpet.NewImageRendererWithCache(terminalpet.DetectImageSupport(os.Getenv), terminalPetFrameCache(options))
petOutput := newPetImageOutput(os.Stdout, petRenderer)
programOpts := []tea.ProgramOption{
tea.WithContext(ctx),
tea.WithInput(os.Stdin),
tea.WithOutput(os.Stdout),
tea.WithOutput(petOutput),
tea.WithFilter(mouseEventFilter()),
}
// Honor the no-color.org spec ourselves: NO_COLOR set to ANY non-empty value
@@ -56,6 +61,7 @@ func Run(ctx context.Context, options Options) int {
programOpts = append(programOpts, tea.WithColorProfile(colorprofile.Ascii))
}
initialModel := newModel(ctx, options)
initialModel.petRenderer = petRenderer
if initialModel.wantsMouseCapture() {
initialModel.mouseCapture = true
}
@@ -88,6 +94,7 @@ func Run(ctx context.Context, options Options) int {
}
_, runErr := program.Run()
clearErr := petOutput.clearImage()
var closeErr error
if peerStarted {
closeErr = options.PeerService.Close()
@@ -102,9 +109,39 @@ func Run(ctx context.Context, options Options) int {
fmt.Fprintln(os.Stderr, "zero: peer messaging cleanup error:", closeErr)
return 1
}
if clearErr != nil {
fmt.Fprintln(os.Stderr, "zero: terminal companion cleanup error:", clearErr)
}
return 0
}
func terminalPetFrameCache(options Options) string {
return terminalPetFrameCacheWith(options, os.UserConfigDir, os.UserCacheDir)
}
func terminalPetFrameCacheWith(options Options, userConfigDir, userCacheDir func() (string, error)) string {
root := ""
if configPath := strings.TrimSpace(options.UserConfigPath); filepath.IsAbs(configPath) {
root = filepath.Dir(filepath.Clean(configPath))
}
if root == "" {
configDir, err := userConfigDir()
if err == nil && strings.TrimSpace(configDir) != "" {
root = filepath.Join(configDir, "zero")
}
}
if root == "" {
cacheDir, err := userCacheDir()
if err == nil && strings.TrimSpace(cacheDir) != "" {
root = filepath.Join(cacheDir, "zero")
}
}
if root == "" {
return ""
}
return filepath.Join(root, "pets", "frame-cache")
}
func useAltScreen(_ Options) bool {
return true
}
+56
View File
@@ -2,7 +2,9 @@ package tui
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
@@ -16,6 +18,60 @@ func TestUseAltScreenForInteractiveChat(t *testing.T) {
}
}
func TestTerminalPetFrameCache(t *testing.T) {
configRoot := filepath.Join(t.TempDir(), "config-root")
cacheRoot := filepath.Join(t.TempDir(), "cache-root")
absConfig := filepath.Join(t.TempDir(), "custom", "config.json")
configDir := func() (string, error) { return configRoot, nil }
cacheDir := func() (string, error) { return cacheRoot, nil }
tests := []struct {
name string
options Options
want string
}{
{name: "absolute config", options: Options{UserConfigPath: absConfig}, want: filepath.Join(filepath.Dir(absConfig), "pets", "frame-cache")},
{name: "relative config falls back", options: Options{UserConfigPath: "config.json"}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
{name: "whitespace config falls back", options: Options{UserConfigPath: " "}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
{name: "empty config falls back", options: Options{}, want: filepath.Join(configRoot, "zero", "pets", "frame-cache")},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := terminalPetFrameCacheWith(test.options, configDir, cacheDir)
if canonicalTestPath(t, got) != canonicalTestPath(t, test.want) {
t.Fatalf("terminalPetFrameCacheWith() = %q, want %q", got, test.want)
}
})
}
unavailable := func() (string, error) { return "", errors.New("unavailable") }
blank := func() (string, error) { return "", nil }
if got, want := terminalPetFrameCacheWith(Options{}, unavailable, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
t.Fatalf("cache fallback = %q, want %q", got, want)
}
if got, want := terminalPetFrameCacheWith(Options{}, blank, cacheDir), filepath.Join(cacheRoot, "zero", "pets", "frame-cache"); canonicalTestPath(t, got) != canonicalTestPath(t, want) {
t.Fatalf("blank config root fallback = %q, want %q", got, want)
}
if got := terminalPetFrameCacheWith(Options{}, unavailable, unavailable); got != "" {
t.Fatalf("unavailable roots returned %q, want empty", got)
}
if got := terminalPetFrameCacheWith(Options{}, unavailable, blank); got != "" {
t.Fatalf("blank cache root returned %q, want empty", got)
}
}
func canonicalTestPath(t *testing.T, value string) string {
t.Helper()
abs, err := filepath.Abs(value)
if err != nil {
t.Fatal(err)
}
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
return resolved
}
return filepath.Clean(abs)
}
// TestRunRejectsNonTTYStdin pins that the interactive shell fails fast with a
// non-zero code when stdin is not a terminal, instead of blocking forever in the
// Bubble Tea event loop (e.g. `echo "" | zero`). The guard runs before any model
+35
View File
@@ -627,6 +627,41 @@ func TestTwoColumnTranscriptViewWidth(t *testing.T) {
}
}
func TestTwoColumnSidebarRemainsFullHeightBesideFooter(t *testing.T) {
m := sidebarTestModel()
m.width, m.height = 120, 34
m.unpricedTokens = 10000
out := plainRender(t, m.twoColumnTranscriptView())
lines := strings.Split(out, "\n")
tokenRow := -1
composerTop := -1
for index, line := range lines {
if strings.Contains(line, "10K tokens") {
tokenRow = index
}
if strings.HasPrefix(line, "╭") {
composerTop = index
}
}
if tokenRow != len(lines)-1 {
t.Fatalf("sidebar token summary should remain pinned to the bottom, row=%d last=%d\n%s", tokenRow, len(lines)-1, out)
}
if composerTop < 0 {
t.Fatalf("chat composer missing:\n%s", out)
}
composerRunes := []rune(lines[composerTop])
if len(composerRunes) != m.width || composerRunes[m.chatColumnWidth()-1] != '╮' {
t.Fatalf("composer should retain the chat-column width, got %q", lines[composerTop])
}
for index, line := range lines {
runes := []rune(line)
if len(runes) != m.width || runes[m.chatColumnWidth()+1] != '│' {
t.Fatalf("sidebar divider ended early on row %d: %q", index, line)
}
}
}
// stripSidebar joins sidebar lines and strips ANSI for content assertions.
func stripSidebar(lines []string) string {
return ansiPattern.ReplaceAllString(strings.Join(lines, "\n"), "")
+11 -4
View File
@@ -172,14 +172,18 @@ func (m model) composerDividerLine(width int) string {
// footer for run-state), so they're not duplicated on this rule.
meta := zeroTheme.muted.Render(model)
metaWidth := lipgloss.Width(meta)
reserved := m.petComposerReservedColumns(width)
availableWidth := width - reserved
if width < 8 {
return zeroTheme.lineStrong.Render(strings.Repeat("─", width))
}
if width < metaWidth+4 {
return zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", width-2) + "╯")
if availableWidth < metaWidth+4 {
line := zeroTheme.lineStrong.Render("╰" + strings.Repeat("─", availableWidth-2) + "╯")
return line + strings.Repeat(" ", reserved)
}
rule := strings.Repeat("─", width-metaWidth-4)
return zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
rule := strings.Repeat("─", availableWidth-metaWidth-4)
line := zeroTheme.lineStrong.Render("╰"+rule+" ") + meta + zeroTheme.lineStrong.Render(" ╯")
return line + strings.Repeat(" ", reserved)
}
// statusLine renders the bottom readout as ` │ `-separated groups: the run-state
@@ -757,6 +761,9 @@ func (m model) pickerOverlay(width int) string {
if m.picker.kind == pickerModel {
return m.modelPickerOverlay(width)
}
if m.picker.kind == pickerPet {
return m.petPickerOverlay(width)
}
overlayWidth := minInt(width, pickerOverlayMaxWidth)
if overlayWidth < pickerOverlayMinWidth {
overlayWidth = width