mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 09:49:34 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d195972df5 | ||
|
|
cbb4a64ffd | ||
|
|
0c4a1f2cf6 | ||
|
|
c26a97da26 | ||
|
|
3c784e716a | ||
|
|
581f29b0ba | ||
|
|
d0fcb2d2b5 | ||
|
|
da2768dccd | ||
|
|
d8e0267ea5 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = ©Value
|
||||
}
|
||||
|
||||
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 = ©Key
|
||||
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 = ©Key
|
||||
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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user