diff --git a/go.mod b/go.mod index eb6b95fcd..9cc9c8148 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d9eec19f0..745a1a09f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/cli/app.go b/internal/cli/app.go index 4adafd4b8..b7d75e0c6 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -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, diff --git a/internal/config/json_object_edit.go b/internal/config/json_object_edit.go new file mode 100644 index 000000000..438d1ff20 --- /dev/null +++ b/internal/config/json_object_edit.go @@ -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) +} diff --git a/internal/config/pet_writer_test.go b/internal/config/pet_writer_test.go new file mode 100644 index 000000000..e83dabed8 --- /dev/null +++ b/internal/config/pet_writer_test.go @@ -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) + } + }) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go index a43659cf0..205a13c68 100644 --- a/internal/config/resolver.go +++ b/internal/config/resolver.go @@ -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) diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go index f443abc83..13038664e 100644 --- a/internal/config/resolver_test.go +++ b/internal/config/resolver_test.go @@ -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. diff --git a/internal/config/types.go b/internal/config/types.go index 7404d47c9..77cc58134 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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). diff --git a/internal/config/writer.go b/internal/config/writer.go index 7c54cc63a..e3b6846f2 100644 --- a/internal/config/writer.go +++ b/internal/config/writer.go @@ -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") diff --git a/internal/terminalpet/client.go b/internal/terminalpet/client.go new file mode 100644 index 000000000..a01a8aec5 --- /dev/null +++ b/internal/terminalpet/client.go @@ -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()) + }) +} diff --git a/internal/terminalpet/client_test.go b/internal/terminalpet/client_test.go new file mode 100644 index 000000000..3f081e687 --- /dev/null +++ b/internal/terminalpet/client_test.go @@ -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://user@assets.petdex.dev/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() +} diff --git a/internal/terminalpet/image_renderer.go b/internal/terminalpet/image_renderer.go new file mode 100644 index 000000000..b2cf37fba --- /dev/null +++ b/internal/terminalpet/image_renderer.go @@ -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() +} diff --git a/internal/terminalpet/image_renderer_test.go b/internal/terminalpet/image_renderer_test.go new file mode 100644 index 000000000..7a8ba4945 --- /dev/null +++ b/internal/terminalpet/image_renderer_test.go @@ -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) + } +} diff --git a/internal/terminalpet/model.go b/internal/terminalpet/model.go new file mode 100644 index 000000000..e67bd4a52 --- /dev/null +++ b/internal/terminalpet/model.go @@ -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) +} diff --git a/internal/terminalpet/model_test.go b/internal/terminalpet/model_test.go new file mode 100644 index 000000000..1ecb65107 --- /dev/null +++ b/internal/terminalpet/model_test.go @@ -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") + } +} diff --git a/internal/terminalpet/sixel.go b/internal/terminalpet/sixel.go new file mode 100644 index 000000000..aa969f5c0 --- /dev/null +++ b/internal/terminalpet/sixel.go @@ -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 +} diff --git a/internal/terminalpet/sixel_erase_test.go b/internal/terminalpet/sixel_erase_test.go new file mode 100644 index 000000000..aa8f454aa --- /dev/null +++ b/internal/terminalpet/sixel_erase_test.go @@ -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) + } + } +} diff --git a/internal/tui/clipboard.go b/internal/tui/clipboard.go index 8e446935e..42e1486f9 100644 --- a/internal/tui/clipboard.go +++ b/internal/tui/clipboard.go @@ -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 diff --git a/internal/tui/commands.go b/internal/tui/commands.go index db1495b93..8eb747cab 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -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"}, diff --git a/internal/tui/modal_selection.go b/internal/tui/modal_selection.go index ef49bcc59..ff7540808 100644 --- a/internal/tui/modal_selection.go +++ b/internal/tui/modal_selection.go @@ -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) diff --git a/internal/tui/model.go b/internal/tui/model.go index 988ee3a52..45e319058 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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. diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 225b4ea8d..1b25fb819 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -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) diff --git a/internal/tui/mouse_filter.go b/internal/tui/mouse_filter.go index 0ead3e000..e38d09af3 100644 --- a/internal/tui/mouse_filter.go +++ b/internal/tui/mouse_filter.go @@ -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 + } +} diff --git a/internal/tui/mouse_filter_test.go b/internal/tui/mouse_filter_test.go index 323d291bd..34ba42d86 100644 --- a/internal/tui/mouse_filter_test.go +++ b/internal/tui/mouse_filter_test.go @@ -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) + } +} diff --git a/internal/tui/options.go b/internal/tui/options.go index bb0980397..554228ebe 100644 --- a/internal/tui/options.go +++ b/internal/tui/options.go @@ -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 diff --git a/internal/tui/pet_output.go b/internal/tui/pet_output.go new file mode 100644 index 000000000..fa87c6caf --- /dev/null +++ b/internal/tui/pet_output.go @@ -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) + }) +} diff --git a/internal/tui/pet_output_test.go b/internal/tui/pet_output_test.go new file mode 100644 index 000000000..821dd92b0 --- /dev/null +++ b/internal/tui/pet_output_test.go @@ -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) + } + } +} diff --git a/internal/tui/pet_sixel_geometry_test.go b/internal/tui/pet_sixel_geometry_test.go new file mode 100644 index 000000000..1d2152d8b --- /dev/null +++ b/internal/tui/pet_sixel_geometry_test.go @@ -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) + } + } +} diff --git a/internal/tui/pets.go b/internal/tui/pets.go new file mode 100644 index 000000000..8e0594612 --- /dev/null +++ b/internal/tui/pets.go @@ -0,0 +1,1065 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + + "github.com/Gitlawb/zero/internal/config" + "github.com/Gitlawb/zero/internal/terminalpet" +) + +const ( + petAmbientImageID uint32 = 0xC0DE + petPreviewImageID uint32 = 0xC0DF + petPreviewDelay = 140 * time.Millisecond + petFrameDelay = 180 * time.Millisecond + petImageColumns = 9 + petImageRows = 5 + petWrapGapColumns = 2 + petReservedColumns = petImageColumns + petWrapGapColumns + petOutcomeHold = 2200 * time.Millisecond + petDoubleClickWindow = 350 * time.Millisecond + petDockSnapColumns = 2 + petResizeEdgeAnchorCells = 2 + petPickerMaxWidth = 58 + petSidePreviewMin = 50 + petPreviewPaneGap = 2 +) + +type petCatalogLoadedMsg struct { + entries []terminalpet.Entry + err error +} + +type petPreviewDebounceMsg struct { + seq uint64 + slug string +} + +type petPreviewLoadedMsg struct { + seq uint64 + slug string + animation *terminalpet.Animation + err error +} + +type petInstalledMsg struct { + entry terminalpet.Entry + animation *terminalpet.Animation + err error +} + +type petTickMsg struct{ seq uint64 } + +func petTickCmd(seq uint64, delays ...time.Duration) tea.Cmd { + delay := petFrameDelay + if len(delays) > 0 && delays[0] > 0 { + delay = delays[0] + } + return tea.Tick(delay, func(time.Time) tea.Msg { return petTickMsg{seq: seq} }) +} + +func (m model) handlePetsCommand(argument string) (tea.Model, tea.Cmd) { + if m.petClient == nil { + return m.appendSystemNotice("Pets are unavailable because the user config directory could not be resolved."), nil + } + if m.petRenderer != nil && !m.petRenderer.Support().Supported() { + return m.appendSystemNotice(m.petRenderer.Support().Reason), nil + } + argument = strings.ToLower(strings.TrimSpace(argument)) + switch argument { + case "off", "disable", "disabled", "hide", "hidden", "none": + m.cancelPetPreview() + if _, err := config.SetPet(m.userConfigPath, terminalpet.DisabledID); err != nil { + return m.appendSystemNotice("Could not disable the terminal companion: " + err.Error()), nil + } + m.petID = terminalpet.DisabledID + m.petName = "" + m.petAnimation = nil + m.petPreview = nil + m.picker = nil + m.petTickSeq++ + return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil + } + m.petRequestedSlug = argument + localEntries, _ := m.petClient.InstalledEntries() + var items []pickerItem + m.petEntries, items = petPickerItems(localEntries) + m.picker = &commandPicker{ + kind: pickerPet, title: "Choose a companion", loading: true, + items: items, allItems: append([]pickerItem{}, items...), selected: selectedPetPickerItem(items, m.petID), + } + m, previewCmd := m.schedulePetPreview() + catalogCmd := func() tea.Msg { + entries, err := m.petClient.Catalog(m.ctx) + return petCatalogLoadedMsg{entries: entries, err: err} + } + return m, batchCommands(previewCmd, catalogCmd) +} + +func (m model) applyPetCatalog(msg petCatalogLoadedMsg) (tea.Model, tea.Cmd) { + if m.picker == nil || m.picker.kind != pickerPet { + return m, nil + } + if msg.err != nil && len(msg.entries) == 0 { + m.picker = nil + return m.appendSystemNotice("Could not load the pet catalog: " + msg.err.Error()), nil + } + var items []pickerItem + m.petEntries, items = petPickerItems(msg.entries) + if requested := strings.TrimSpace(m.petRequestedSlug); requested != "" { + m.petRequestedSlug = "" + if requested == terminalpet.DisabledID { + return m.installPet(requested) + } + if _, ok := m.petEntries[requested]; !ok { + m.picker = nil + return m.appendSystemNotice(fmt.Sprintf("No pet named %q. Run /pets to search the catalog.", requested)), nil + } + return m.installPet(requested) + } + query := m.picker.query + m.picker = &commandPicker{ + kind: pickerPet, title: "Choose a companion", items: items, + allItems: append([]pickerItem{}, items...), query: query, selected: selectedPetPickerItem(items, m.petID), + } + m.picker.applyQuery() + return m.schedulePetPreview() +} + +func petPickerItems(entries []terminalpet.Entry) (map[string]terminalpet.Entry, []pickerItem) { + entryBySlug := make(map[string]terminalpet.Entry, len(entries)) + items := make([]pickerItem, 0, len(entries)+1) + items = append(items, pickerItem{Label: "No companion", Value: terminalpet.DisabledID, Meta: "off"}) + for _, entry := range entries { + entryBySlug[entry.Slug] = entry + group := "Discover" + if entry.Local { + group = "Installed" + } + items = append(items, pickerItem{Group: group, Label: entry.Label(), Value: entry.Slug, Local: entry.Local, Remote: !entry.Local}) + } + return entryBySlug, items +} + +func selectedPetPickerItem(items []pickerItem, petID string) int { + for index, item := range items { + if item.Value == petID { + return index + } + } + return 0 +} + +func (m model) schedulePetPreview() (model, tea.Cmd) { + m.cancelPetPreview() + m.petPreviewSeq++ + m.petPreview = nil + m.petPreviewError = "" + if m.picker == nil { + m.petPreviewLoading = false + m.petPreviewSlug = "" + return m, nil + } + item, ok := m.picker.current() + if !ok || item.Value == terminalpet.DisabledID { + m.petPreviewLoading = false + m.petPreviewSlug = "" + return m, nil + } + m.petPreviewLoading = true + m.petPreviewSlug = item.Value + seq := m.petPreviewSeq + slug := item.Value + return m, tea.Tick(petPreviewDelay, func(time.Time) tea.Msg { + return petPreviewDebounceMsg{seq: seq, slug: slug} + }) +} + +func (m model) startPetPreview(msg petPreviewDebounceMsg) (tea.Model, tea.Cmd) { + if msg.seq != m.petPreviewSeq || m.picker == nil || m.picker.kind != pickerPet { + return m, nil + } + item, ok := m.picker.current() + if !ok || item.Value != msg.slug { + return m, nil + } + entry, ok := m.petEntries[msg.slug] + if !ok { + return m, nil + } + ctx, cancel := context.WithCancel(m.ctx) + m.petPreviewCancel = cancel + return m, func() tea.Msg { + animation, err := m.petClient.Preview(ctx, entry) + return petPreviewLoadedMsg{seq: msg.seq, slug: msg.slug, animation: animation, err: err} + } +} + +func (m model) applyPetPreview(msg petPreviewLoadedMsg) model { + if msg.seq != m.petPreviewSeq || msg.slug != m.petPreviewSlug { + return m + } + m.petPreviewCancel = nil + m.petPreviewLoading = false + if msg.err != nil { + if !errorsIsContext(msg.err) { + m.petPreviewError = "Preview unavailable" + } + return m + } + m.petPreview = msg.animation + m.petPreviewError = "" + return m +} + +func errorsIsContext(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func (m *model) cancelPetPreview() { + if m.petPreviewCancel != nil { + m.petPreviewCancel() + m.petPreviewCancel = nil + } +} + +func (m model) installPet(slug string) (tea.Model, tea.Cmd) { + m.cancelPetPreview() + m.picker = nil + if slug == terminalpet.DisabledID { + if _, err := config.SetPet(m.userConfigPath, terminalpet.DisabledID); err != nil { + return m.appendSystemNotice("Could not save the pet preference: " + err.Error()), nil + } + m.petID = terminalpet.DisabledID + m.petName = "" + m.petAnimation = nil + m.petTickSeq++ + return m.appendSystemNotice("Terminal companion hidden. Run /pets to choose another."), nil + } + entry, ok := m.petEntries[slug] + if !ok { + return m.appendSystemNotice(fmt.Sprintf("Pet %q is no longer in the catalog.", slug)), nil + } + m = m.appendSystemNotice("Installing " + entry.Label() + "…") + return m, func() tea.Msg { + animation, err := m.petClient.Install(m.ctx, entry) + return petInstalledMsg{entry: entry, animation: animation, err: err} + } +} + +func (m model) applyPetInstall(msg petInstalledMsg) (tea.Model, tea.Cmd) { + if msg.err != nil { + return m.appendSystemNotice("Could not install " + msg.entry.Label() + ": " + msg.err.Error()), nil + } + if _, err := config.SetPet(m.userConfigPath, msg.entry.Slug); err != nil { + return m.appendSystemNotice("The pet was downloaded but could not be selected: " + err.Error()), nil + } + m.petID = msg.entry.Slug + m.petName = msg.entry.Label() + m.petAnimation = msg.animation + m.petPhase = 0 + m.petTickSeq++ + m.petPlaybackState = terminalpet.Idle + m.petClickAnimationIndex = 0 + m.petOutcome = terminalpet.Idle + m = m.appendSystemNotice(msg.entry.Label() + " is now your terminal companion. Use /pets off to hide it.") + if m.reducedMotion { + return m, nil + } + return m, petTickCmd(m.petTickSeq, m.petFrameDelay()) +} + +func (m model) petPickerOverlay(width int) string { + if m.picker == nil { + return "" + } + overlayWidth := minInt(width, petPickerMaxWidth) + if overlayWidth < pickerOverlayMinWidth { + overlayWidth = width + } + innerWidth := maxInt(1, overlayWidth-4) + item, hasItem := m.picker.current() + hasPreviewTarget := hasItem && item.Value != terminalpet.DisabledID + sidePreview := hasPreviewTarget && innerWidth >= petSidePreviewMin + listWidth := innerWidth + if sidePreview { + listWidth -= petImageColumns + petPreviewPaneGap + } + previewDivider := zeroTheme.line.Render("│") + " " + listLine := func(line string) string { + line = fitStyledLine(line, listWidth) + if !sidePreview { + return line + } + return padStyledLine(line, listWidth) + previewDivider + strings.Repeat(" ", petImageColumns) + } + listHeight := minInt(8, len(m.picker.items)) + start := 0 + if len(m.picker.items) > 0 { + m.picker.selected = clampInt(m.picker.selected, 0, len(m.picker.items)-1) + start = selectableListStart(len(m.picker.items), listHeight, m.picker.selected) + } + lines := []string{renderPickerSearchLine(m.picker.query, "search companions…", innerWidth), zeroTheme.line.Render(strings.Repeat("─", innerWidth))} + listStartRow := len(lines) + if len(m.picker.items) == 0 { + lines = append(lines, listLine(zeroTheme.faint.Render(" no matching companions"))) + } else { + lastGroup := "" + for index, item := range m.picker.items[start : start+listHeight] { + if item.Group != "" && item.Group != lastGroup { + lines = append(lines, listLine(zeroTheme.accent.Render(item.Group))) + lastGroup = item.Group + } + selected := start+index == m.picker.selected + surface := transparentSurface + marker := " " + if selected { + surface = zeroTheme.onSel + marker = "❯ " + } + name := truncatePetPickerColumn(item.Label, maxInt(1, listWidth-lipgloss.Width(marker))) + lines = append(lines, listLine(surface(zeroTheme.accent).Render(marker)+surface(zeroTheme.ink).Render(name))) + } + } + if m.picker.loading { + lines = append(lines, listLine(zeroTheme.accent.Render("Discover"))) + lines = append(lines, listLine(zeroTheme.faint.Render(" Fetching companions…"))) + } + if sidePreview && m.petPreview != nil { + for len(lines)-listStartRow < petImageRows { + lines = append(lines, listLine("")) + } + } + if sidePreview && len(lines) > listStartRow { + previewTitle := centerRenderedBlock(zeroTheme.accent.Render("Preview"), petImageColumns) + lines[listStartRow] = padStyledLine(ansi.Cut(lines[listStartRow], 0, listWidth), listWidth) + + previewDivider + previewTitle + } + lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth))) + switch { + case m.petPreviewLoading: + lines = append(lines, zeroTheme.faint.Render("Loading preview…")) + case m.petPreviewError != "": + lines = append(lines, zeroTheme.faint.Render(m.petPreviewError)) + case m.petPreview != nil && !sidePreview: + for range petImageRows { + lines = append(lines, "") + } + } + if hasItem && item.Value != terminalpet.DisabledID { + if entry, ok := m.petEntries[item.Value]; ok { + details := []string{entry.Label()} + if kind := strings.TrimSpace(entry.Kind); kind != "" { + details = append(details, kind) + } + if entry.SubmittedBy != "" { + details = append(details, "by "+entry.SubmittedBy) + } + lines = append(lines, centerRenderedBlock(zeroTheme.faint.Render(strings.Join(details, " · ")), innerWidth)) + } + } + lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", innerWidth))) + lines = append(lines, zeroTheme.faint.Render("↑/↓ preview Enter select Esc close")) + return centerRenderedBlock(styledBlockFillTitle(overlayWidth, "Choose a companion", lines, zeroTheme.lineStrong, lipgloss.NewStyle()), width) +} + +func truncatePetPickerColumn(value string, width int) string { + value = strings.TrimSpace(value) + if width <= 0 || value == "" { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + if width == 1 { + return "…" + } + return strings.TrimSpace(ansi.Cut(value, 0, width-1)) + "…" +} + +func (m model) petLayoutActive() bool { + if m.petLayoutRendering { + return false + } + if m.petAnimation == nil || m.petID == "" || m.petID == terminalpet.DisabledID { + return false + } + if m.petRenderer != nil && !m.petRenderer.Support().Supported() { + return false + } + layoutWidth := m.width + if m.sidebarActive() { + layoutWidth = m.chatColumnWidth() + } + if !m.altScreen || layoutWidth < petImageColumns+4 || m.height < petImageRows+8 || m.subchat.active || m.transcriptDetailed { + return false + } + if m.petObscuringModalActive() || m.suggestionsActive() || m.setup.visible || m.helpOverlay || m.leaderHelpOverlay { + return false + } + return true +} + +// A permission prompt replaces the composer but does not take over the whole +// viewport, so the ambient companion can remain visible beside it. Other modal +// surfaces own or cover the viewport and continue to suppress the companion. +func (m model) petObscuringModalActive() bool { + return m.pendingAskUser != nil || m.pendingSpecReview != nil || m.providerWizard != nil || + m.mcpAddWizard != nil || m.mcpManager != nil || m.picker != nil || + m.sttKeyPrompt != nil || m.renamePrompt != nil +} + +func (m model) petComposerReservedColumns(width int) int { + if !m.petLayoutRendering && !m.petLayoutActive() { + return 0 + } + if m.petSupportsAlphaOverlay() && (m.petPositionSet || (m.petDragActive && !m.petDragStartedDocked)) { + return 0 + } + return minInt(petReservedColumns, maxInt(0, width-8)) +} + +func (m model) footerStatusLine(width int) string { + reserved := m.petComposerReservedColumns(width) + return m.statusLine(width-reserved) + strings.Repeat(" ", reserved) +} + +func (m model) floatingPetTranscriptView() string { + chatModel := m + chatModel.petLayoutRendering = true + chat := m.reservePetImageSlot(viewLines(chatModel.transcriptView()), m.width) + return strings.Join(chat, "\n") +} + +func (m model) reservePetImageSlot(lines []string, width int) []string { + chat := append([]string(nil), lines...) + if m.petSupportsAlphaOverlay() { + return chat + } + start := maxInt(0, len(chat)-petImageRows-1) + rightStart := maxInt(0, width-petReservedColumns) + for row := start; row < minInt(len(chat)-1, start+petImageRows); row++ { + line := fitStyledLine(chat[row], width) + chat[row] = padStyledLine(ansi.Cut(line, 0, rightStart), rightStart) + strings.Repeat(" ", width-rightStart) + } + return chat +} + +func (m model) petSupportsAlphaOverlay() bool { + if m.petRenderer == nil { + return false + } + switch m.petRenderer.Support().Protocol { + case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile: + return true + default: + return false + } +} + +func (m model) petImageDraw(content string) *terminalpet.ImageDraw { + if m.petRenderer == nil || !m.petRenderer.Support().Supported() { + return nil + } + if m.picker != nil && m.picker.kind == pickerPet && m.petPreview != nil { + x, y, ok := petPickerImagePosition(content, petImageColumns, petImageRows) + if !ok { + return nil + } + heightPixels := m.petImageHeightPixels(m.petPreview, terminalpet.Idle, m.petPhase) + columns, rows := m.petImageCells(m.petPreview, terminalpet.Idle, m.petPhase, heightPixels) + return &terminalpet.ImageDraw{ + ID: petPreviewImageID, Animation: m.petPreview, State: terminalpet.Idle, Phase: m.petPhase, + X: x, Y: y, Columns: columns, Rows: rows, + HeightPixels: heightPixels, + } + } + if !m.petLayoutActive() { + return nil + } + x, y := m.ambientPetPosition(m.width, m.height) + offsetX, offsetY := m.ambientPetOffset(x, y, m.width, m.height) + phase := m.petPhase + if state := m.petState(); state != m.petPlaybackState { + // State changes are visible before the timer for the previous state fires. + // Start the new track at its first frame instead of briefly rendering it + // at the old track's phase. + phase = 0 + } + heightPixels := m.petImageHeightPixels(m.petAnimation, m.petState(), phase) + columns, rows := m.petImageCells(m.petAnimation, m.petState(), phase, heightPixels) + return &terminalpet.ImageDraw{ + ID: petAmbientImageID, Animation: m.petAnimation, State: m.petState(), Phase: phase, + X: x, Y: y, OffsetX: offsetX, OffsetY: offsetY, + Columns: columns, Rows: rows, + HeightPixels: heightPixels, + } +} + +// petImageCells reports the cell footprint to record for an image. +// +// For Kitty this is the placement REQUEST, so the constants are the answer: the +// terminal scales the image into that many cells and owns the region. +// +// For sixel it is what will later be ERASED, so it has to describe what was +// actually painted, and the constants are wrong for that. The rendered height is +// clamped to preferredHeight, so the true row count is ceil(height/cellHeight), +// which equals petImageRows only when a cell happens to be exactly 15 pixels +// tall. At a 20-pixel cell it is 4 rows while the erase blanked 5, taking a row +// of interface with it every time the companion moved. +func (m model) petImageCells(animation *terminalpet.Animation, state terminalpet.State, phase, heightPixels int) (int, int) { + if m.petRenderer == nil || m.petRenderer.Support().Protocol != terminalpet.ImageProtocolSixel { + return petImageColumns, petImageRows + } + if m.petCellPixelWidth <= 0 || m.petCellPixelHeight <= 0 || heightPixels <= 0 { + return petImageColumns, petImageRows + } + rows := ceilDivInt(heightPixels, m.petCellPixelHeight) + columns := petImageColumns + if animation != nil { + if frame := animation.Frame(state, phase); frame != nil && frame.Bounds().Dy() > 0 && frame.Bounds().Dx() > 0 { + widthPixels := heightPixels * frame.Bounds().Dx() / frame.Bounds().Dy() + columns = ceilDivInt(widthPixels, m.petCellPixelWidth) + } + } + // Never beyond the reserved area: the layout keeps that many cells clear and + // the drag clamps to it, so erasing past it would reach live interface even + // when the arithmetic above says the image is larger. + return clampInt(columns, 1, petImageColumns), clampInt(rows, 1, petImageRows) +} + +// ceilDivInt rounds up, because a sixel covering part of a cell still dirties +// the whole cell and the erase has to cover it. +func ceilDivInt(value, divisor int) int { + if divisor <= 0 { + return 0 + } + return (value + divisor - 1) / divisor +} + +func (m model) petImageHeightPixels(animation *terminalpet.Animation, state terminalpet.State, phase int) int { + const preferredHeight = 75 + if m.petRenderer == nil || m.petRenderer.Support().Protocol != terminalpet.ImageProtocolSixel || m.petCellPixelHeight <= 0 { + return preferredHeight + } + height := minInt(preferredHeight, petImageRows*m.petCellPixelHeight) + if m.petCellPixelWidth <= 0 || animation == nil { + return maxInt(1, height) + } + frame := animation.Frame(state, phase) + if frame == nil || frame.Bounds().Dx() <= 0 || frame.Bounds().Dy() <= 0 { + return maxInt(1, height) + } + widthLimitedHeight := petImageColumns * m.petCellPixelWidth * frame.Bounds().Dy() / frame.Bounds().Dx() + return maxInt(1, minInt(height, widthLimitedHeight)) +} + +func (m model) ambientPetPosition(width, height int) (int, int) { + maxX := maxInt(0, width-petImageColumns) + maxY := maxInt(0, height-petImageRows) + if m.petSupportsAlphaOverlay() { + if m.petDragActive { + return clampInt(m.petDragTargetX, 0, maxX), clampInt(m.petDragTargetY, 0, maxY) + } + if m.petPositionSet { + return clampInt(m.petPositionX, 0, maxX), clampInt(m.petPositionY, 0, maxY) + } + } + return m.petHomePosition(width, height) +} + +func (m *model) resizeFreePetPosition(oldWidth, oldHeight, newWidth, newHeight int) { + if !m.petPositionSet || oldWidth <= 0 || oldHeight <= 0 { + return + } + oldMaxX := maxInt(0, oldWidth-petImageColumns) + oldMaxY := maxInt(0, oldHeight-petImageRows) + newMaxX := maxInt(0, newWidth-petImageColumns) + newMaxY := maxInt(0, newHeight-petImageRows) + if m.petCellPixelWidth > 0 && m.petCellPixelHeight > 0 { + x := m.petPositionX*m.petCellPixelWidth + m.petPositionOffsetX + y := m.petPositionY*m.petCellPixelHeight + m.petPositionOffsetY + x = resizePetCoordinate(x, oldMaxX*m.petCellPixelWidth, newMaxX*m.petCellPixelWidth, petResizeEdgeAnchorCells*m.petCellPixelWidth) + y = resizePetCoordinate(y, oldMaxY*m.petCellPixelHeight, newMaxY*m.petCellPixelHeight, petResizeEdgeAnchorCells*m.petCellPixelHeight) + m.petPositionX, m.petPositionOffsetX = x/m.petCellPixelWidth, x%m.petCellPixelWidth + m.petPositionY, m.petPositionOffsetY = y/m.petCellPixelHeight, y%m.petCellPixelHeight + return + } + m.petPositionX = resizePetCoordinate(m.petPositionX, oldMaxX, newMaxX, petResizeEdgeAnchorCells) + m.petPositionY = resizePetCoordinate(m.petPositionY, oldMaxY, newMaxY, petResizeEdgeAnchorCells) +} + +func resizePetCoordinate(value, oldMaximum, newMaximum, edgeThreshold int) int { + if newMaximum <= 0 { + return 0 + } + if oldMaximum <= 0 { + return clampInt(value, 0, newMaximum) + } + value = clampInt(value, 0, oldMaximum) + if value <= edgeThreshold { + return clampInt(value, 0, newMaximum) + } + if gap := oldMaximum - value; gap <= edgeThreshold { + return clampInt(newMaximum-gap, 0, newMaximum) + } + return clampInt(value+newMaximum/2-oldMaximum/2, 0, newMaximum) +} + +func (m model) petHomePosition(width, height int) (int, int) { + if m.sidebarActive() { + width = m.chatColumnWidth() + } + maxX := maxInt(0, width-petImageColumns) + maxY := maxInt(0, height-petImageRows) + return clampInt(width-petImageColumns-2, 0, maxX), clampInt(height-petImageRows-1, 0, maxY) +} + +func (m model) ambientPetOffset(x, y, width, height int) (int, int) { + offsetX, offsetY := 0, 0 + if m.petSupportsAlphaOverlay() { + if m.petDragActive { + offsetX, offsetY = m.petDragTargetOffsetX, m.petDragTargetOffsetY + } else if m.petPositionSet { + offsetX, offsetY = m.petPositionOffsetX, m.petPositionOffsetY + } + } + if x >= maxInt(0, width-petImageColumns) { + offsetX = 0 + } + if y >= maxInt(0, height-petImageRows) { + offsetY = 0 + } + return maxInt(0, offsetX), maxInt(0, offsetY) +} + +func (m model) petPixelProtocolSupported() bool { + if m.petRenderer == nil { + return false + } + switch m.petRenderer.Support().Protocol { + case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile: + return true + default: + return false + } +} + +func (m model) petPixelDragAvailable() bool { + return m.petPixelProtocolSupported() && m.petCellPixelWidth > 0 && m.petCellPixelHeight > 0 +} + +// petCellMetricsWanted reports whether to ask the terminal for its cell size. +// +// Kept SEPARATE from petPixelProtocolSupported, which gates pixel-precise +// dragging, because the two questions only looked like one. Dragging is a Kitty +// feature; the measurement is needed by every image protocol, and by sixel most +// of all. +// +// Sixel was excluded from the request, so on a sixel terminal the reply never +// arrived, petCellPixelHeight stayed zero, petImageHeightPixels fell to its +// blind preferredHeight, and the erase kept using the petImageColumns and +// petImageRows constants. Those constants describe the RESERVED area, not what +// was painted: at a 20-pixel cell a 75-pixel sprite is 4 rows, so the erase +// blanked a fifth row of live interface every time the companion moved. +// +// Windows Terminal answers this request, so it was never a question of terminal +// support. Nothing asked it. +func (m model) petCellMetricsWanted() bool { + if m.petRenderer == nil { + return false + } + switch m.petRenderer.Support().Protocol { + case terminalpet.ImageProtocolKitty, terminalpet.ImageProtocolKittyLocalFile, terminalpet.ImageProtocolSixel: + return true + default: + return false + } +} + +func petPixelMouseEnableCmd() tea.Cmd { + return tea.Raw(ansi.SetModeMouseExtSgrPixel) +} + +func petPixelMouseDisableCmd() tea.Cmd { + return tea.Raw(ansi.ResetModeMouseExtSgrPixel + ansi.SetModeMouseExtSgr) +} + +func petImageFlushCmd() tea.Cmd { + return tea.Raw(terminalSyncStart + terminalSyncEnd) +} + +func petPixelMouseDisableAndFlushCmd() tea.Cmd { + return tea.Raw(ansi.ResetModeMouseExtSgrPixel + ansi.SetModeMouseExtSgr + terminalSyncStart + terminalSyncEnd) +} + +func (m model) petHit(x, y int) bool { + // Permission choices own all pointer input even though the companion remains + // visible. It must never intercept an approval or denial click. + if m.pendingPermission != nil || !m.petLayoutActive() { + return false + } + petX, petY := m.ambientPetPosition(m.width, m.height) + return x >= petX && x < petX+petImageColumns && y >= petY && y < petY+petImageRows +} + +func (m model) handlePetMouse(msg tea.MouseMsg) (model, tea.Cmd, bool) { + x, y := mouseX(msg), mouseY(msg) + switch { + case mouseLeftPress(msg) && m.petHit(x, y): + petX, petY := m.ambientPetPosition(m.width, m.height) + petOffsetX, petOffsetY := m.ambientPetOffset(petX, petY, m.width, m.height) + m.petDragActive = true + m.petDragMoved = false + m.petDragStartedDocked = !m.petPositionSet + m.petDragOffsetX = x - petX + m.petDragOffsetY = y - petY + m.petDragTargetX = petX + m.petDragTargetY = petY + m.petDragTargetOffsetX = petOffsetX + m.petDragTargetOffsetY = petOffsetY + m.petDragState = terminalpet.Idle + m.petPixelDrag = m.petPixelDragAvailable() + m.petPixelAnchorSet = false + if m.petPixelDrag { + return m, petPixelMouseEnableCmd(), true + } + return m, nil, true + case mouseMotion(msg) && m.petDragActive && m.petPixelDrag: + previousState := m.petDragState + leavePixelMode := m.updatePixelPetTarget(x, y) + animationCmd := m.restartPetDragPlayback(previousState) + if leavePixelMode { + return m.leavePixelPetDrag(), batchCommands(petPixelMouseDisableAndFlushCmd(), animationCmd), true + } + return m, batchCommands(petImageFlushCmd(), animationCmd), true + case mouseMotion(msg) && m.petDragActive && !m.petSupportsAlphaOverlay(): + // Sixel images are part of the terminal cell grid. Keep them in the + // dedicated dock so moving one can never overwrite cells Bubble Tea owns. + return m, nil, true + case mouseMotion(msg) && m.petDragActive: + oldX, oldY := m.petDragTargetX, m.petDragTargetY + newX := clampInt(x-m.petDragOffsetX, 0, maxInt(0, m.width-petImageColumns)) + newY := clampInt(y-m.petDragOffsetY, 0, maxInt(0, m.height-petImageRows)) + if newX == oldX && newY == oldY { + return m, nil, true + } + m.petDragTargetX = newX + m.petDragTargetY = newY + m.petDragTargetOffsetX = 0 + m.petDragTargetOffsetY = 0 + m.petDragMoved = true + previousState := m.petDragState + m.petDragState = petMovementState(oldX, newX, previousState) + return m, batchCommands(petImageFlushCmd(), m.restartPetDragPlayback(previousState)), true + case mouseRelease(msg) && m.petDragActive && m.petPixelDrag: + _ = m.updatePixelPetTarget(x, y) + return m.finishPetDrag(petPixelMouseDisableAndFlushCmd()) + case mouseRelease(msg) && m.petDragActive: + return m.finishPetDrag(nil) + default: + return m, nil, false + } +} + +func (m *model) updatePixelPetTarget(pointerX, pointerY int) bool { + cellWidth, cellHeight := m.petCellPixelWidth, m.petCellPixelHeight + if cellWidth <= 0 || cellHeight <= 0 { + return false + } + if !m.petPixelAnchorSet { + m.petDragOffsetPixelX = m.petDragOffsetX*cellWidth + positiveRemainder(pointerX, cellWidth) + m.petDragOffsetPixelY = m.petDragOffsetY*cellHeight + positiveRemainder(pointerY, cellHeight) + m.petPixelAnchorSet = true + } + oldAbsoluteX, oldAbsoluteY := m.petDragAbsolutePosition() + maxAbsoluteX := maxInt(0, (m.width-petImageColumns)*cellWidth) + maxAbsoluteY := maxInt(0, (m.height-petImageRows)*cellHeight) + newAbsoluteX := clampInt(pointerX-m.petDragOffsetPixelX, 0, maxAbsoluteX) + newAbsoluteY := clampInt(pointerY-m.petDragOffsetPixelY, 0, maxAbsoluteY) + terminalPixelWidth := m.width * cellWidth + terminalPixelHeight := m.height * cellHeight + nearEdge := pointerX <= cellWidth || pointerY <= cellHeight || + terminalPixelWidth-pointerX <= cellWidth || terminalPixelHeight-pointerY <= cellHeight + if newAbsoluteX == oldAbsoluteX && newAbsoluteY == oldAbsoluteY { + return nearEdge + } + m.setPetDragAbsolutePosition(newAbsoluteX, newAbsoluteY) + m.petDragMoved = true + m.petDragState = petMovementState(oldAbsoluteX, newAbsoluteX, m.petDragState) + return nearEdge +} + +func petMovementState(oldX, newX int, current terminalpet.State) terminalpet.State { + switch { + case newX > oldX: + return terminalpet.MoveRight + case newX < oldX: + return terminalpet.MoveLeft + case current == terminalpet.MoveLeft || current == terminalpet.MoveRight: + return current + default: + // A vertical-only drag still represents movement. With no prior facing + // direction, use the right-running row as the stable default. + return terminalpet.MoveRight + } +} + +func (m *model) restartPetDragPlayback(previous terminalpet.State) tea.Cmd { + if m.petDragState == previous { + return nil + } + m.petPhase = 0 + m.petPlaybackState = m.petDragState + if m.reducedMotion || m.petAnimation == nil { + return nil + } + m.petTickSeq++ + return petTickCmd(m.petTickSeq, m.petFrameDelay()) +} + +func (m model) leavePixelPetDrag() model { + if m.petCellPixelWidth > 0 { + m.petDragOffsetX = clampInt(m.petDragOffsetPixelX/m.petCellPixelWidth, 0, petImageColumns-1) + } + if m.petCellPixelHeight > 0 { + m.petDragOffsetY = clampInt(m.petDragOffsetPixelY/m.petCellPixelHeight, 0, petImageRows-1) + } + m.petPixelDrag = false + m.petPixelAnchorSet = false + return m +} + +func (m model) petDragAbsolutePosition() (int, int) { + return m.petDragTargetX*m.petCellPixelWidth + m.petDragTargetOffsetX, + m.petDragTargetY*m.petCellPixelHeight + m.petDragTargetOffsetY +} + +func (m *model) setPetDragAbsolutePosition(x, y int) { + if m.petCellPixelWidth <= 0 || m.petCellPixelHeight <= 0 { + return + } + m.petDragTargetX, m.petDragTargetOffsetX = x/m.petCellPixelWidth, x%m.petCellPixelWidth + m.petDragTargetY, m.petDragTargetOffsetY = y/m.petCellPixelHeight, y%m.petCellPixelHeight +} + +func positiveRemainder(value, divisor int) int { + if divisor <= 0 { + return 0 + } + remainder := value % divisor + if remainder < 0 { + remainder += divisor + } + return remainder +} + +func (m model) finishPetDrag(modeCmd tea.Cmd) (model, tea.Cmd, bool) { + m.petDragActive = false + m.petPixelDrag = false + m.petPixelAnchorSet = false + m.petDragState = terminalpet.Idle + if m.petDragMoved { + m.commitPetDragPosition() + m.petDragStartedDocked = false + return m, modeCmd, true + } + now := m.now() + if action, ok := m.petAnimation.ClickAnimation(m.petClickAnimationIndex); ok { + m.petOutcome = action + m.petClickAnimationIndex++ + m.petLastClickAt = time.Time{} + } else if !m.petLastClickAt.IsZero() && now.Sub(m.petLastClickAt) <= petDoubleClickWindow { + m.petOutcome = terminalpet.Jumping + m.petLastClickAt = time.Time{} + } else { + m.petOutcome = terminalpet.Waving + m.petLastClickAt = now + } + m.petPhase = 0 + if m.petOutcome == terminalpet.Waving { + // The default waving row begins with the same neutral stance as idle. + // Start on its first visibly active frame so a click feels immediate; + // later repeated passes still include the neutral transition frame. + m.petPhase = 1 + } + m.petPlaybackState = m.petOutcome + m.petOutcomeAt = now + m.petDragStartedDocked = false + if m.reducedMotion || m.petAnimation == nil { + return m, modeCmd, true + } + // The existing ticker may still be sleeping on a long idle-frame delay. + // Replace it so the click action advances at its authored cadence from the + // moment the user releases the pet. + m.petTickSeq++ + return m, batchCommands(modeCmd, petTickCmd(m.petTickSeq, m.petFrameDelay())), true +} + +func (m *model) commitPetDragPosition() { + if m.petDragTargetIsDocked() { + m.petPositionSet = false + m.petPositionX, m.petPositionY = 0, 0 + m.petPositionOffsetX, m.petPositionOffsetY = 0, 0 + } else { + m.petPositionSet = true + m.petPositionX = m.petDragTargetX + m.petPositionY = m.petDragTargetY + m.petPositionOffsetX = m.petDragTargetOffsetX + m.petPositionOffsetY = m.petDragTargetOffsetY + } + m.petDragMoved = false + m.petLastClickAt = time.Time{} +} + +func (m model) petDragTargetIsDocked() bool { + homeX, homeY := m.petHomePosition(m.width, m.height) + if m.petCellPixelWidth > 0 && m.petCellPixelHeight > 0 { + targetX := m.petDragTargetX*m.petCellPixelWidth + m.petDragTargetOffsetX + targetY := m.petDragTargetY*m.petCellPixelHeight + m.petDragTargetOffsetY + verticalDelta := targetY - homeY*m.petCellPixelHeight + return petAbs(targetX-homeX*m.petCellPixelWidth) <= petDockSnapColumns*m.petCellPixelWidth && + verticalDelta >= -m.petCellPixelHeight && verticalDelta <= maxInt(1, m.petCellPixelHeight/2) + } + return petAbs(m.petDragTargetX-homeX) <= petDockSnapColumns && + m.petDragTargetY >= homeY-1 && m.petDragTargetY <= homeY +} + +func petAbs(value int) int { + if value < 0 { + return -value + } + return value +} + +func (m *model) cancelPetDrag() { + m.petDragActive = false + m.petPixelDrag = false + m.petPixelAnchorSet = false + m.petDragMoved = false + m.petDragStartedDocked = false + m.petDragState = terminalpet.Idle +} + +func petPickerImagePosition(content string, columns, rows int) (int, int, bool) { + lines := viewLines(content) + top, search, footer := -1, -1, -1 + for index, line := range lines { + plain := ansi.Strip(line) + if top < 0 && strings.Contains(plain, "Choose a companion") { + top = index + } + if search < 0 && strings.Contains(plain, "search >") { + search = index + if top < 0 { + top = index + if index > 0 { + previous := ansi.Strip(lines[index-1]) + searchLeft := len(plain) - len(strings.TrimLeft(plain, " ")) + previousLeft := len(previous) - len(strings.TrimLeft(previous, " ")) + if previousLeft == searchLeft { + // Narrow stacked previews use the border row immediately above + // search for their full modal width. A clipped picker may instead + // expose a transcript separator there, whose indentation differs. + top = index - 1 + } + } + } + } + if top >= 0 && strings.Contains(plain, "↑/↓ preview") { + footer = index + break + } + } + if top < 0 { + return 0, 0, false + } + topLine := ansi.Strip(lines[top]) + left := len(topLine) - len(strings.TrimLeft(topLine, " ")) + overlayWidth := len([]rune(strings.TrimRight(topLine, " "))) - left + if overlayWidth < columns { + return 0, 0, false + } + if overlayWidth-4 >= petSidePreviewMin && search >= 0 { + scanEnd := len(lines) + if footer >= 0 { + scanEnd = footer + } + firstRule, secondRule := -1, -1 + for index := search + 1; index < scanEnd; index++ { + if strings.Count(ansi.Strip(lines[index]), "─") < columns { + continue + } + if firstRule < 0 { + firstRule = index + } else { + secondRule = index + break + } + } + listTop := firstRule + 1 + if firstRule >= 0 && secondRule-listTop >= rows { + x := left + overlayWidth - columns - 2 + y := listTop + (secondRule-listTop-rows)/2 + return x, y, true + } + } + if footer < top || footer-rows-2 < 0 { + return 0, 0, false + } + return left + (overlayWidth-columns)/2, footer - rows - 2, true +} + +func (m model) petState() terminalpet.State { + if m.petDragActive && m.petDragState != terminalpet.Idle { + return m.petDragState + } + if m.pendingPermission != nil || m.pendingAskUser != nil || m.pendingSpecReview != nil { + return terminalpet.Waiting + } + if m.pending { + return terminalpet.Running + } + if m.petOutcome != "" { + hold := petOutcomeHold + if duration, customClick := m.petAnimation.ClickDuration(m.petOutcome); customClick && duration > 0 { + hold = duration + } else if duration := m.petAnimation.PrimaryDuration(m.petOutcome); duration > hold { + hold = duration + } + if m.now().Sub(m.petOutcomeAt) < hold { + return m.petOutcome + } + } + return terminalpet.Idle +} + +func (m model) petPlayback() (*terminalpet.Animation, terminalpet.State) { + if m.picker != nil && m.picker.kind == pickerPet && m.petPreview != nil { + return m.petPreview, terminalpet.Idle + } + return m.petAnimation, m.petState() +} + +func (m model) petFrameDelay() time.Duration { + animation, state := m.petPlayback() + if animation == nil { + return petFrameDelay + } + phase := m.petPhase + if state != m.petPlaybackState { + phase = 0 + } + if delay := animation.FrameDelay(state, phase); delay > 0 { + return delay + } + return petFrameDelay +} diff --git a/internal/tui/pets_test.go b/internal/tui/pets_test.go new file mode 100644 index 000000000..ece522128 --- /dev/null +++ b/internal/tui/pets_test.go @@ -0,0 +1,1392 @@ +package tui + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Gitlawb/zero/internal/terminalpet" + uv "github.com/charmbracelet/ultraviolet" + "github.com/charmbracelet/x/ansi" +) + +func TestParsePetsCommandAndAlias(t *testing.T) { + for _, input := range []string{"/pets", "/pet boba"} { + parsed := parseCommand(input) + if parsed.kind != commandPets { + t.Fatalf("parseCommand(%q).kind = %v, want commandPets", input, parsed.kind) + } + } +} + +func TestPetPickerOverlayIncludesPreview(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width = 100 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{{Label: "Boba", Value: "boba"}}, selected: 0} + m.petEntries["boba"] = terminalpet.Entry{Slug: "boba", DisplayName: "Boba", SubmittedBy: "tester"} + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + for y := range 12 { + for x := range 12 { + frame.SetNRGBA(x, y, color.NRGBA{R: 120, G: 80, B: 200, A: 255}) + } + } + animation, err := terminalpet.ThumbnailAnimation(frame) + if err != nil { + t.Fatal(err) + } + m.petPreview = animation + plain := plainRender(t, m.petPickerOverlay(m.width)) + if !strings.Contains(plain, "Boba · by tester") || !strings.Contains(plain, "Enter select") { + t.Fatalf("pet overlay lacks preview detail or controls: %q", plain) + } +} + +func TestPetPickerPasteSanitizesMultilineQuery(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.picker = &commandPicker{kind: pickerPet} + + updated, _ := m.routePaste("Luffy\nGear\t5") + next := updated.(model) + if got := next.picker.query; got != "LuffyGear 5" { + t.Fatalf("picker paste query = %q, want sanitized single line", got) + } +} + +func TestPetPickerRowsShowOnlyNamesAndFooterUsesCatalogMetadata(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width = 100 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{{Label: "Boba", Value: "boba"}}, selected: 0} + m.petEntries["boba"] = terminalpet.Entry{Slug: "boba", DisplayName: "Boba", Kind: "creature", SubmittedBy: "tester"} + + plain := plainRender(t, m.petPickerOverlay(m.width)) + if !strings.Contains(plain, "Boba · creature · by tester") { + t.Fatalf("pet footer should show name, kind, and creator:\n%s", plain) + } + for _, line := range strings.Split(plain, "\n") { + if strings.Contains(line, "❯ Boba") && (strings.Contains(line, "creature") || strings.Contains(line, "tester")) { + t.Fatalf("pet list row should contain only the name: %q", line) + } + } +} + +func TestPetCatalogUsesClearLocalAndRemoteGroupLabels(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.picker = &commandPicker{kind: pickerPet} + updated, _ := m.applyPetCatalog(petCatalogLoadedMsg{entries: []terminalpet.Entry{ + {Slug: "local", DisplayName: "Local", Local: true}, + {Slug: "remote", DisplayName: "Remote"}, + }}) + next := updated.(model) + want := []pickerItem{ + {Label: "No companion", Value: terminalpet.DisabledID, Meta: "off"}, + {Group: "Installed", Label: "Local", Value: "local", Local: true}, + {Group: "Discover", Label: "Remote", Value: "remote", Remote: true}, + } + if !reflect.DeepEqual(next.picker.items, want) { + t.Fatalf("pet picker items = %#v, want %#v", next.picker.items, want) + } +} + +func TestPetsPickerShowsLocalChoicesWhileDiscoverLoads(t *testing.T) { + root := t.TempDir() + installedDir := filepath.Join(root, "pets", "installed", "kratos") + if err := os.MkdirAll(installedDir, 0o700); err != nil { + t.Fatal(err) + } + metadata := `{"slug":"kratos","displayName":"Kratos Greek","kind":"character","submittedBy":"tester","spriteVersionNumber":1}` + if err := os.WriteFile(filepath.Join(installedDir, "source.json"), []byte(metadata), 0o600); err != nil { + t.Fatal(err) + } + m := newModel(context.Background(), Options{}) + m.width = 100 + m.petClient = terminalpet.NewClient(root) + m.petID = "kratos" + updated, cmd := m.handlePetsCommand("") + next := updated.(model) + if cmd == nil { + t.Fatal("opening the pet picker should continue loading Discover entries") + } + if next.picker == nil || !next.picker.loading { + t.Fatalf("pet picker = %#v, want local rows plus loading state", next.picker) + } + want := []pickerItem{ + {Label: "No companion", Value: terminalpet.DisabledID, Meta: "off"}, + {Group: "Installed", Label: "Kratos Greek", Value: "kratos", Local: true}, + } + if !reflect.DeepEqual(next.picker.items, want) { + t.Fatalf("initial pet picker items = %#v, want %#v", next.picker.items, want) + } + if current, ok := next.picker.current(); !ok || current.Value != "kratos" { + t.Fatalf("initial pet picker selection = %#v, %v; want installed current pet", current, ok) + } + plain := plainRender(t, next.petPickerOverlay(next.width)) + for _, wantText := range []string{"No companion", "Installed", "Kratos Greek", "Discover", "Fetching companions"} { + if !strings.Contains(plain, wantText) { + t.Fatalf("loading pet picker missing %q:\n%s", wantText, plain) + } + } +} + +func TestPetCatalogPreservesQueryTypedWhileLoading(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.picker = &commandPicker{kind: pickerPet, loading: true, query: "boba"} + updated, _ := m.applyPetCatalog(petCatalogLoadedMsg{entries: []terminalpet.Entry{ + {Slug: "kratos", DisplayName: "Kratos", Local: true}, + {Slug: "boba", DisplayName: "Boba"}, + }}) + next := updated.(model) + if next.picker.query != "boba" || len(next.picker.items) != 1 || next.picker.items[0].Value != "boba" { + t.Fatalf("loaded picker did not preserve query: %#v", next.picker) + } +} + +func TestPetPickerRowsTruncateLongNames(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.width = 100 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{{ + Label: "A very long companion name that keeps going far beyond the available picker row width and must be truncated safely", + Value: "long-pet", + }}, selected: 0} + m.petEntries["long-pet"] = terminalpet.Entry{Slug: "long-pet", DisplayName: "Long pet", Kind: "character", SubmittedBy: "tester"} + + plain := plainRender(t, m.petPickerOverlay(m.width)) + var row string + for _, line := range strings.Split(plain, "\n") { + if strings.Contains(line, "A very long companion") { + row = line + break + } + } + if row == "" { + t.Fatalf("pet row missing:\n%s", plain) + } + if !strings.Contains(row, "…") || strings.Contains(row, "truncated safely") { + t.Fatalf("pet row should truncate its long name: %q", row) + } +} + +func TestPetPickerViewSchedulesTerminalImageInsteadOfANSIArt(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{{Label: "Boba", Value: "boba"}}, selected: 0} + m.petEntries["boba"] = terminalpet.Entry{Slug: "boba", DisplayName: "Boba", SubmittedBy: "tester"} + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + for y := range 12 { + for x := range 12 { + frame.SetNRGBA(x, y, color.NRGBA{R: 120, G: 80, B: 200, A: 255}) + } + } + m.petPreview, _ = terminalpet.ThumbnailAnimation(frame) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + view := m.View() + if strings.Contains(plainRender(t, view.Content), "▀") { + t.Fatalf("pet picker still contains ANSI image cells: %q", plainRender(t, view.Content)) + } + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=9,r=5") { + t.Fatalf("pet picker did not schedule a Kitty image: %q", got) + } +} + +func TestPetPickerPreviewUsesRightPaneWhenWide(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{ + {Group: "Discover", Label: "Aion", Value: "aion"}, + {Group: "Discover", Label: "AirRing", Value: "airring"}, + {Group: "Discover", Label: "Akane", Value: "akane"}, + }, selected: 1} + m.petEntries["airring"] = terminalpet.Entry{Slug: "airring", DisplayName: "AirRing", SubmittedBy: "tester"} + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petPreview, _ = terminalpet.ThumbnailAnimation(frame) + + content := m.petPickerOverlay(m.width) + x, y, ok := petPickerImagePosition(content, petImageColumns, petImageRows) + if !ok { + t.Fatalf("wide picker should provide preview image geometry:\n%s", plainRender(t, content)) + } + if x < m.width*2/3 { + t.Fatalf("wide picker preview x=%d, want a right-side pane in width %d", x, m.width) + } + detailRow := -1 + for index, line := range viewLines(content) { + if strings.Contains(ansi.Strip(line), "AirRing · by tester") { + detailRow = index + break + } + } + if detailRow < 0 || y+petImageRows > detailRow { + t.Fatalf("preview rows [%d,%d) should sit beside the list above detail row %d:\n%s", y, y+petImageRows, detailRow, plainRender(t, content)) + } + previewRow := "" + for _, line := range viewLines(content) { + plain := ansi.Strip(line) + if strings.Contains(plain, "Preview") { + previewRow = plain + break + } + } + if previewRow == "" { + t.Fatalf("wide picker should label its preview pane:\n%s", plainRender(t, content)) + } + if !strings.Contains(previewRow, "│") { + t.Fatalf("wide picker should separate the list and preview panes: %q", previewRow) + } + if strings.Contains(previewRow, "…") { + t.Fatalf("preview heading row should not truncate the list pane: %q", previewRow) + } +} + +func TestPetPickerPreviewUsesRightPaneWhenTitleAndFooterAreClipped(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{ + {Group: "Installed", Label: "Dasheng", Value: "dasheng"}, + {Group: "Installed", Label: "Ddo-zvzo", Value: "ddo-zvzo"}, + {Group: "Installed", Label: "Doraemon", Value: "doraemon"}, + {Group: "Installed", Label: "Kratos Greek", Value: "kratos-greek"}, + {Group: "Installed", Label: "Luffy", Value: "luffy"}, + {Group: "Installed", Label: "Luffy Gear 5", Value: "luffy-gear-5"}, + {Group: "Installed", Label: "Sasuke Uchiha", Value: "sasuke-uchiha"}, + }, selected: 1} + m.petEntries["kratos-greek"] = terminalpet.Entry{Slug: "kratos-greek", DisplayName: "Kratos Greek", SubmittedBy: "tester"} + m.petPreview, _ = terminalpet.ThumbnailAnimation(image.NewNRGBA(image.Rect(0, 0, 12, 12))) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + lines := viewLines(m.petPickerOverlay(m.width)) + search, footer := -1, -1 + for index, line := range lines { + if strings.Contains(ansi.Strip(line), "search >") { + search = index + } + if strings.Contains(ansi.Strip(line), "↑/↓ preview") { + footer = index + break + } + } + if search < 0 || footer < 0 { + t.Fatalf("picker search or footer missing from test fixture") + } + clipped := append([]string{strings.Repeat("─", m.width)}, lines[search:footer]...) + content := strings.Join(clipped, "\n") + + x, _, ok := petPickerImagePosition(content, petImageColumns, petImageRows) + if !ok { + t.Fatalf("clipped picker should retain right-pane preview geometry:\n%s", plainRender(t, content)) + } + searchRunes := []rune(ansi.Strip(lines[search])) + rightBorder := -1 + for index := len(searchRunes) - 1; index >= 0; index-- { + if searchRunes[index] == '│' { + rightBorder = index + break + } + } + if rightBorder < 0 || x+petImageColumns > rightBorder { + t.Fatalf("clipped picker preview [%d,%d) escapes modal right border %d:\n%s", x, x+petImageColumns, rightBorder, plainRender(t, content)) + } + if draw := m.petImageDraw(content); draw == nil { + t.Fatal("clipped picker should still schedule its preview image") + } +} + +func TestPetPickerHidesPreviewPaneForNoCompanion(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{ + {Label: "No companion", Value: terminalpet.DisabledID}, + {Group: "Discover", Label: "Boba", Value: "boba"}, + }, selected: 0} + + plain := plainRender(t, m.petPickerOverlay(m.width)) + if strings.Contains(plain, "Preview") { + t.Fatalf("no-companion selection should not show an empty preview pane:\n%s", plain) + } +} + +func TestPetPickerMouseWheelSchedulesSelectedPreview(t *testing.T) { + m := mouseTestModel() + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{ + {Label: "Alpha", Value: "alpha"}, + {Label: "Beta", Value: "beta"}, + }, selected: 0} + m.petEntries["alpha"] = terminalpet.Entry{Slug: "alpha"} + m.petEntries["beta"] = terminalpet.Entry{Slug: "beta"} + m.petPreviewSlug = "alpha" + m.petPreviewLoading = false + + updated, cmd := m.Update(testMouseWheel(tea.MouseWheelDown, 1, 1)) + next := updated.(model) + if next.picker.selected != 1 { + t.Fatalf("wheel selected index = %d, want 1", next.picker.selected) + } + if next.petPreviewSlug != "beta" || !next.petPreviewLoading { + t.Fatalf("wheel preview = %q loading=%v, want beta loading", next.petPreviewSlug, next.petPreviewLoading) + } + if cmd == nil { + t.Fatal("wheel selection should schedule the selected pet preview") + } +} + +func TestPetPickerPasteFiltersAndSchedulesSelectedPreview(t *testing.T) { + m := mouseTestModel() + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{ + {Label: "Kratos Greek", Value: "kratos-greek"}, + {Label: "Luffy Gear 5", Value: "luffy-gear-5"}, + }, allItems: []pickerItem{ + {Label: "Kratos Greek", Value: "kratos-greek"}, + {Label: "Luffy Gear 5", Value: "luffy-gear-5"}, + }} + m.petEntries["kratos-greek"] = terminalpet.Entry{Slug: "kratos-greek"} + m.petEntries["luffy-gear-5"] = terminalpet.Entry{Slug: "luffy-gear-5"} + + updated, cmd := m.Update(tea.PasteMsg{Content: "luffy"}) + next := updated.(model) + if next.picker.query != "luffy" { + t.Fatalf("pasted pet query = %q, want luffy", next.picker.query) + } + if len(next.picker.items) != 1 || next.picker.items[0].Value != "luffy-gear-5" { + t.Fatalf("pasted pet results = %#v, want only Luffy Gear 5", next.picker.items) + } + if next.petPreviewSlug != "luffy-gear-5" || cmd == nil { + t.Fatalf("pasted pet preview = %q cmd=%v, want luffy-gear-5 and refresh", next.petPreviewSlug, cmd) + } +} + +func TestPetPickerPreviewStacksBelowListWhenNarrow(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 50, 28 + m.picker = &commandPicker{kind: pickerPet, title: "Choose a companion", items: []pickerItem{{Label: "Boba", Value: "boba"}}, selected: 0} + m.petEntries["boba"] = terminalpet.Entry{Slug: "boba", DisplayName: "Boba"} + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petPreview, _ = terminalpet.ThumbnailAnimation(frame) + + content := m.petPickerOverlay(m.width) + x, y, ok := petPickerImagePosition(content, petImageColumns, petImageRows) + if !ok { + t.Fatalf("narrow picker should provide stacked preview geometry:\n%s", plainRender(t, content)) + } + if x < m.width/3 || x > m.width*2/3 { + t.Fatalf("narrow picker preview x=%d, want centered in width %d", x, m.width) + } + listRow := -1 + for index, line := range viewLines(content) { + if strings.Contains(ansi.Strip(line), "Boba") { + listRow = index + break + } + } + if listRow < 0 || y <= listRow { + t.Fatalf("stacked preview row %d should follow list row %d:\n%s", y, listRow, plainRender(t, content)) + } +} + +func TestAmbientPetFreePositionTracksViewportResize(t *testing.T) { + m := interactivePetTestModel(t) + m.width, m.height = 89, 25 + m.petPositionSet = true + originalCenterX := (m.width - petImageColumns) / 2 + originalCenterY := (m.height - petImageRows) / 2 + m.petPositionX = originalCenterX + 12 + m.petPositionY = originalCenterY - 6 + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 169, Height: 45}) + next := updated.(model) + wantX := (next.width-petImageColumns)/2 + 12 + wantY := (next.height-petImageRows)/2 - 6 + if next.petPositionX != wantX || next.petPositionY != wantY { + t.Fatalf("resized free pet = (%d,%d), want preserved center offset (%d,%d)", + next.petPositionX, next.petPositionY, wantX, wantY) + } + + updated, _ = next.Update(tea.WindowSizeMsg{Width: 89, Height: 25}) + roundTrip := updated.(model) + if roundTrip.petPositionX != m.petPositionX || roundTrip.petPositionY != m.petPositionY { + t.Fatalf("round-trip resize moved free pet to (%d,%d), want (%d,%d)", + roundTrip.petPositionX, roundTrip.petPositionY, m.petPositionX, m.petPositionY) + } +} + +func TestAmbientPetSubCellPositionTracksViewportResize(t *testing.T) { + m := interactivePetTestModel(t) + m.width, m.height = 89, 25 + m.petCellPixelWidth, m.petCellPixelHeight = 8, 16 + m.petPositionSet = true + m.petPositionX, m.petPositionOffsetX = 20, 4 + m.petPositionY, m.petPositionOffsetY = 5, 7 + originalX := m.petPositionX*m.petCellPixelWidth + m.petPositionOffsetX + originalY := m.petPositionY*m.petCellPixelHeight + m.petPositionOffsetY + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 169, Height: 45}) + next := updated.(model) + gotX := next.petPositionX*next.petCellPixelWidth + next.petPositionOffsetX + gotY := next.petPositionY*next.petCellPixelHeight + next.petPositionOffsetY + oldMaxX := (m.width - petImageColumns) * m.petCellPixelWidth + oldMaxY := (m.height - petImageRows) * m.petCellPixelHeight + newMaxX := (next.width - petImageColumns) * next.petCellPixelWidth + newMaxY := (next.height - petImageRows) * next.petCellPixelHeight + wantX := originalX + newMaxX/2 - oldMaxX/2 + wantY := originalY + newMaxY/2 - oldMaxY/2 + if gotX != wantX || gotY != wantY { + t.Fatalf("resized sub-cell pet = (%d,%d), want preserved center offset (%d,%d)", gotX, gotY, wantX, wantY) + } + + updated, _ = next.Update(tea.WindowSizeMsg{Width: 89, Height: 25}) + roundTrip := updated.(model) + gotX = roundTrip.petPositionX*roundTrip.petCellPixelWidth + roundTrip.petPositionOffsetX + gotY = roundTrip.petPositionY*roundTrip.petCellPixelHeight + roundTrip.petPositionOffsetY + if gotX != originalX || gotY != originalY { + t.Fatalf("round-trip sub-cell resize = (%d,%d), want (%d,%d)", gotX, gotY, originalX, originalY) + } +} + +func TestAmbientPetBottomRightPositionTracksViewportEdges(t *testing.T) { + m := interactivePetTestModel(t) + m.width, m.height = 89, 25 + m.petPositionSet = true + oldMaxX := m.width - petImageColumns + oldMaxY := m.height - petImageRows + m.petPositionX = oldMaxX - 2 + m.petPositionY = oldMaxY - 1 + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 169, Height: 45}) + next := updated.(model) + wantX := next.width - petImageColumns - 2 + wantY := next.height - petImageRows - 1 + if next.petPositionX != wantX || next.petPositionY != wantY { + t.Fatalf("resized bottom-right pet = (%d,%d), want preserved edge gaps (%d,%d)", + next.petPositionX, next.petPositionY, wantX, wantY) + } +} + +func TestAmbientPetSubCellBottomRightPositionTracksViewportEdges(t *testing.T) { + m := interactivePetTestModel(t) + m.width, m.height = 89, 25 + m.petCellPixelWidth, m.petCellPixelHeight = 8, 16 + m.petPositionSet = true + oldMaxX := (m.width - petImageColumns) * m.petCellPixelWidth + oldMaxY := (m.height - petImageRows) * m.petCellPixelHeight + originalX := oldMaxX - 7 + originalY := oldMaxY - 5 + m.petPositionX, m.petPositionOffsetX = originalX/m.petCellPixelWidth, originalX%m.petCellPixelWidth + m.petPositionY, m.petPositionOffsetY = originalY/m.petCellPixelHeight, originalY%m.petCellPixelHeight + + updated, _ := m.Update(tea.WindowSizeMsg{Width: 169, Height: 45}) + next := updated.(model) + gotX := next.petPositionX*next.petCellPixelWidth + next.petPositionOffsetX + gotY := next.petPositionY*next.petCellPixelHeight + next.petPositionOffsetY + wantX := (next.width-petImageColumns)*next.petCellPixelWidth - 7 + wantY := (next.height-petImageRows)*next.petCellPixelHeight - 5 + if gotX != wantX || gotY != wantY { + t.Fatalf("resized sub-cell bottom-right pet = (%d,%d), want preserved edge gaps (%d,%d)", + gotX, gotY, wantX, wantY) + } +} + +func TestPetLayoutRequiresRoomAndNoModal(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.altScreen, m.width, m.height = true, 120, 30 + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + animation, _ := terminalpet.ThumbnailAnimation(frame) + m.petID, m.petAnimation = "boba", animation + if !m.petLayoutActive() { + t.Fatal("pet layout should be active in a wide, non-modal transcript") + } + m.picker = &commandPicker{kind: pickerPet} + if m.petLayoutActive() { + t.Fatal("pet layout should hide behind a modal") + } +} + +func TestAmbientPetFloatsWithoutSidebarDivider(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + view := m.View() + plain := plainRender(t, view.Content) + dividerColumn := m.width - petReservedColumns + for _, line := range strings.Split(plain, "\n") { + runes := []rune(line) + if len(runes) > dividerColumn && runes[dividerColumn] == '│' { + t.Fatalf("ambient pet rendered a sidebar divider at column %d: %q", dividerColumn, line) + } + } + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=9,r=5") { + t.Fatalf("ambient pet did not use floating image geometry: %q", got) + } +} + +func TestAmbientPetRemainsVisibleInNarrowTerminal(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 60, 24 + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + _ = m.View() + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + if got := output.String(); !strings.Contains(got, "_Ga=T,t=d,f=100,c=9,r=5") { + t.Fatalf("narrow terminal hid the ambient pet: %q", got) + } +} + +func TestAmbientPetRetainsSingleColumnComposerDock(t *testing.T) { + m := newModel(context.Background(), Options{AltScreen: true}) + m.width, m.height = 110, 34 + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + m.unpricedTokens = 11700 + + view := m.View() + plain := plainRender(t, view.Content) + composerWidth := 0 + for _, line := range strings.Split(plain, "\n") { + if strings.Contains(line, "╭") && strings.Contains(line, "─") { + composerWidth = len([]rune(strings.TrimRight(line, " "))) + } + } + if composerWidth != m.width-petReservedColumns { + t.Fatalf("visible composer width = %d, want dock to start at %d", composerWidth, m.width-petReservedColumns) + } + dockEdge := m.width - petReservedColumns - 1 + foundTop, foundInput := false, false + for _, line := range strings.Split(plain, "\n") { + runes := []rune(line) + if len(runes) < m.width { + continue + } + if strings.HasPrefix(line, "╭") { + foundTop = true + if runes[dockEdge] != '╮' { + t.Fatalf("composer top is not closed before the pet dock: %q", line) + } + } + if strings.HasPrefix(line, "│") && strings.Contains(line, "describe a task for zero") { + foundInput = true + if runes[dockEdge] != '│' { + t.Fatalf("composer input is not closed before the pet dock: %q", line) + } + } + } + if !foundTop || !foundInput { + t.Fatalf("composer rows missing: top=%v input=%v", foundTop, foundInput) + } + expectedY := len(viewLines(view.Content)) - petImageRows - 1 + expectedX := m.width - petImageColumns - 2 + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + wantCursor := fmt.Sprintf("\x1b[%d;%dH", expectedY+1, expectedX+1) + if got := output.String(); !strings.Contains(got, wantCursor) { + t.Fatalf("pet cursor missing %q: %q", wantCursor, got) + } +} + +func TestAmbientPetDoesNotChangeSidebarFooterGeometry(t *testing.T) { + m := sidebarTestModel() + m.width, m.height = 120, 34 + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + view := m.View() + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + wantCursor := fmt.Sprintf("\x1b[%d;%dH", len(viewLines(view.Content))-petImageRows, m.chatColumnWidth()-petImageColumns-1) + if got := output.String(); !strings.Contains(got, wantCursor) { + t.Fatalf("sidebar hid or misplaced pet; cursor missing %q: %q", wantCursor, got) + } + + plain := plainRender(t, view.Content) + for _, line := range strings.Split(plain, "\n") { + if !strings.HasPrefix(line, "╭") { + continue + } + runes := []rune(line) + dockEdge := m.chatColumnWidth() - petReservedColumns - 1 + if len(runes) < m.width || runes[dockEdge] != '╮' || runes[m.chatColumnWidth()+1] != '│' { + t.Fatalf("pet changed the normal full-height sidebar geometry: %q", line) + } + return + } + t.Fatal("sidebar composer top not found") +} + +func TestDockedPetFollowsSidebarLayoutWithoutBecomingFreePositioned(t *testing.T) { + m := sidebarTestModel() + m.width, m.height = 120, 34 + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + + withSidebarX, _ := m.ambientPetPosition(m.width, m.height) + if want := m.chatColumnWidth() - petImageColumns - 2; withSidebarX != want { + t.Fatalf("sidebar dock x = %d, want chat-column dock %d", withSidebarX, want) + } + m.sidebarHidden = true + withoutSidebarX, _ := m.ambientPetPosition(m.width, m.height) + if want := m.width - petImageColumns - 2; withoutSidebarX != want { + t.Fatalf("single-column dock x = %d, want %d", withoutSidebarX, want) + } + if m.petPositionSet { + t.Fatal("layout transitions must not turn a docked pet into a free-positioned pet") + } +} + +func TestAmbientPetDragMovesAndClampsInsideViewport(t *testing.T) { + m := interactivePetTestModel(t) + startX, startY := m.ambientPetPosition(m.width, m.height) + + next, _, handled := m.handlePetMouse(testMouseClick(tea.MouseLeft, startX+2, startY+2)) + if !handled || !next.petDragActive { + t.Fatal("press inside the pet should start a drag") + } + next, _, handled = next.handlePetMouse(testMouseMotion(tea.MouseLeft, -20, -20)) + if !handled || next.petDragTargetX != 0 || next.petDragTargetY != 0 { + t.Fatalf("drag target should clamp to top-left, got (%d,%d)", next.petDragTargetX, next.petDragTargetY) + } + next, _, handled = next.handlePetMouse(testMouseRelease(tea.MouseLeft, -20, -20)) + if !handled || next.petDragActive || !next.petPositionSet { + t.Fatalf("release should finish and retain the drag: %#v", next) + } + draw := next.petImageDraw(next.transcriptView()) + if draw == nil || draw.X != 0 || draw.Y != 0 { + t.Fatalf("dragged pet draw = %#v, want top-left", draw) + } +} + +func TestAmbientPetDragKeepsAdvancingFrames(t *testing.T) { + m := interactivePetTestModel(t) + m.petPhase = 7 + m.petPlaybackState = terminalpet.Idle + startX, startY := m.ambientPetPosition(m.width, m.height) + m, _, handled := m.handlePetMouse(testMouseClick(tea.MouseLeft, startX+2, startY+2)) + if !handled || !m.petDragActive { + t.Fatal("press inside the pet should start a drag") + } + m.petPhase = 12 + draw := m.petImageDraw(m.transcriptView()) + if draw == nil { + t.Fatal("held pet draw is nil") + } + if draw.Phase != 12 { + t.Fatalf("held pet phase = %d, want current phase 12", draw.Phase) + } +} + +func TestAmbientPetDragAllowsBottomEdge(t *testing.T) { + m := interactivePetTestModel(t) + startX, startY := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, startX+2, startY+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, startX+2, m.height+20)) + m, _, _ = m.handlePetMouse(testMouseRelease(tea.MouseLeft, startX+2, m.height+20)) + + draw := m.petImageDraw(m.transcriptView()) + if draw == nil { + t.Fatal("dragged pet draw is nil") + } + if draw.Y != m.height-petImageRows { + t.Fatalf("dragged pet y = %d, want bottom-edge row %d", draw.Y, m.height-petImageRows) + } +} + +func TestAmbientKittyPetRestoresDockAfterFreeDrag(t *testing.T) { + m := interactivePetTestModel(t) + homeX, homeY := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, homeX+2, homeY+2)) + if got := m.petComposerReservedColumns(m.width); got != petReservedColumns { + t.Fatalf("pressing a docked pet changed composer reservation to %d", got) + } + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, 30, 12)) + if got := m.petComposerReservedColumns(m.width); got != petReservedColumns { + t.Fatalf("dragging away reflowed composer before release: reservation=%d", got) + } + m, _, _ = m.handlePetMouse(testMouseRelease(tea.MouseLeft, 30, 12)) + if !m.petPositionSet || m.petComposerReservedColumns(m.width) != 0 { + t.Fatal("moving a Kitty pet should release the composer dock") + } + + freeX, freeY := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, freeX+2, freeY+2)) + if got := m.petComposerReservedColumns(m.width); got != 0 { + t.Fatalf("dragging a free pet recreated the dock reservation: %d", got) + } + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, homeX+2, homeY+2)) + if got := m.petComposerReservedColumns(m.width); got != 0 { + t.Fatalf("hovering over the dock reflowed composer before release: %d", got) + } + m, _, _ = m.handlePetMouse(testMouseRelease(tea.MouseLeft, homeX+2, homeY+2)) + if m.petPositionSet { + t.Fatal("returning the pet home should restore its docked state") + } + if got := m.petComposerReservedColumns(m.width); got != petReservedColumns { + t.Fatalf("restored Kitty dock reservation = %d, want %d", got, petReservedColumns) + } +} + +func TestAmbientPetDrawAndHitUseTerminalHeight(t *testing.T) { + m := interactivePetTestModel(t) + m.petPositionSet = true + m.petPositionX = 20 + m.petPositionY = m.height - petImageRows + draw := m.petImageDraw("short content") + if draw == nil || draw.Y != m.petPositionY { + t.Fatalf("draw position = %#v, want terminal-relative y %d", draw, m.petPositionY) + } + if !m.petHit(draw.X+1, draw.Y+1) { + t.Fatal("rendered pet position did not match its hit target") + } +} + +func TestPetComposerDividerFallbackPreservesDockedColumns(t *testing.T) { + m := interactivePetTestModel(t) + m.modelName = strings.Repeat("long-model-name", 8) + + line := plainRender(t, m.composerDividerLine(m.width)) + if got := ansi.StringWidth(line); got != m.width { + t.Fatalf("divider width = %d, want %d", got, m.width) + } + if suffix := strings.Repeat(" ", petReservedColumns); !strings.HasSuffix(line, suffix) { + t.Fatalf("divider did not preserve %d docked columns: %q", petReservedColumns, line) + } +} + +func TestAmbientSixelPetRetainsDockedComposerFallbackSlot(t *testing.T) { + m := interactivePetTestModel(t) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}) + if got := m.petComposerReservedColumns(m.width); got != petReservedColumns { + t.Fatalf("docked Sixel fallback reservation = %d, want %d", got, petReservedColumns) + } +} + +func TestAmbientSixelPetCannotLeaveReservedDock(t *testing.T) { + m := interactivePetTestModel(t) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}) + m.petPositionSet = true + m.petPositionX, m.petPositionY = 3, 2 + homeX, homeY := m.petHomePosition(m.width, m.height) + + if x, y := m.ambientPetPosition(m.width, m.height); x != homeX || y != homeY { + t.Fatalf("Sixel pet position = (%d,%d), want reserved dock (%d,%d)", x, y, homeX, homeY) + } + m.petPositionSet = false + m, _, handled := m.handlePetMouse(testMouseClick(tea.MouseLeft, homeX+2, homeY+2)) + if !handled || !m.petDragActive { + t.Fatal("Sixel pet press should remain available for click animations") + } + m, _, handled = m.handlePetMouse(testMouseMotion(tea.MouseLeft, 2, 2)) + if !handled || m.petDragMoved { + t.Fatal("Sixel pet motion must not move it out of the reserved dock") + } + m, _, handled = m.handlePetMouse(testMouseRelease(tea.MouseLeft, 2, 2)) + if !handled || m.petPositionSet || m.petDragActive { + t.Fatal("Sixel pet release must keep the reserved dock without leaving drag state") + } + if got := m.petComposerReservedColumns(m.width); got != petReservedColumns { + t.Fatalf("Sixel dock reservation = %d, want %d", got, petReservedColumns) + } +} + +func TestAmbientKittyPetStillSupportsFreePositioning(t *testing.T) { + m := interactivePetTestModel(t) + homeX, homeY := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, homeX+2, homeY+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, 2, 2)) + m, _, _ = m.handlePetMouse(testMouseRelease(tea.MouseLeft, 2, 2)) + + if !m.petPositionSet { + t.Fatal("Kitty pet no longer supports free positioning") + } + if x, y := m.ambientPetPosition(m.width, m.height); x == homeX && y == homeY { + t.Fatal("Kitty pet remained docked after a free-positioning drag") + } +} + +func TestSixelPetFitsItsReservedTerminalRows(t *testing.T) { + m := interactivePetTestModel(t) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}) + m.petCellPixelHeight = 10 + draw := m.petImageDraw(m.transcriptView()) + if draw == nil { + t.Fatal("Sixel pet draw is unavailable") + } + want := petImageRows * m.petCellPixelHeight + if draw.HeightPixels != want { + t.Fatalf("Sixel height = %dpx, want reserved height %dpx", draw.HeightPixels, want) + } +} + +func TestAmbientPetRemainsVisibleButNotInteractiveDuringPermissionPrompt(t *testing.T) { + m := sidebarTestModel() + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + m.pendingPermission = &pendingPermissionPrompt{request: testPromptPermissionRequest()} + + view := m.View() + var output bytes.Buffer + if err := m.petRenderer.Render(&output); err != nil { + t.Fatal(err) + } + if !strings.Contains(output.String(), "_Ga=T") { + t.Fatalf("permission prompt hid the ambient pet: %q", output.String()) + } + + draw := m.petImageDraw(view.Content) + wantX := m.chatColumnWidth() - petImageColumns - 2 + if draw == nil || draw.X != wantX { + t.Fatalf("permission pet should remain in the chat-column dock at x=%d: %#v", wantX, draw) + } + x, y := draw.X, draw.Y + if m.petHit(x+1, y+1) { + t.Fatal("a pet shown during a permission prompt must not intercept modal clicks") + } +} + +func TestAmbientPetDragReleaseDoesNotClearScreen(t *testing.T) { + m := interactivePetTestModel(t) + homeX, homeY := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, homeX+2, homeY+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, 30, 12)) + m, cmd, _ := m.handlePetMouse(testMouseRelease(tea.MouseLeft, 30, 12)) + if cmdIncludesClearScreen(cmd) { + t.Fatal("releasing a freely positioned pet must not flash a full-screen clear") + } + + freeX, freeY := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, freeX+2, freeY+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, homeX+2, homeY+2)) + _, cmd, _ = m.handlePetMouse(testMouseRelease(tea.MouseLeft, homeX+2, homeY+2)) + if cmdIncludesClearScreen(cmd) { + t.Fatal("snapping a pet into its dock must not flash a full-screen clear") + } +} + +func TestAmbientPetDockSnapAcceptsNearbySubCellPosition(t *testing.T) { + m := interactivePetTestModel(t) + m.petCellPixelWidth, m.petCellPixelHeight = 8, 16 + homeX, homeY := m.petHomePosition(m.width, m.height) + m.petDragTargetX = homeX - 1 + m.petDragTargetY = homeY + m.petDragTargetOffsetX = 7 + m.petDragTargetOffsetY = 7 + if !m.petDragTargetIsDocked() { + t.Fatal("a visually returned sub-cell position should snap into the dock") + } + m.petDragTargetOffsetY = 9 + if m.petDragTargetIsDocked() { + t.Fatal("a position beyond half a row should remain freely positioned") + } + m.petDragTargetY = homeY - 1 + m.petDragTargetOffsetY = 0 + if !m.petDragTargetIsDocked() { + t.Fatal("the row immediately above home should remain inside the visual dock target") + } + m.petDragTargetY = homeY + 1 + if m.petDragTargetIsDocked() { + t.Fatal("the row below home should remain available for free bottom-edge placement") + } +} + +func TestAmbientPetDragFollowsPointerBeforeRelease(t *testing.T) { + m := interactivePetTestModel(t) + x, y := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, 25, 12)) + + content := m.transcriptView() + draw := m.petImageDraw(content) + if draw == nil || draw.X != m.petDragTargetX || draw.Y != m.petDragTargetY { + t.Fatalf("dragged pet should follow target (%d,%d), got %#v", m.petDragTargetX, m.petDragTargetY, draw) + } + if plain := plainRender(t, content); strings.Contains(plain, "│ move │") { + t.Fatalf("drag should not draw a placeholder:\n%s", plain) + } +} + +func TestAmbientPetPixelDragUsesSubCellOffsetsOnlyWhileHeld(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = true + m.petCellPixelWidth = 8 + m.petCellPixelHeight = 16 + x, y := m.ambientPetPosition(m.width, m.height) + + m, cmd, handled := m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + if !handled || !m.petPixelDrag || cmd == nil { + t.Fatal("pressing a Kitty pet with known cell pixels should start pixel drag mode") + } + if !petCommandIncludesRaw(cmd, ansi.SetModeMouseExtSgrPixel) { + t.Fatal("pixel drag command does not include pixel mouse mode enable") + } + + pointerX := (x+2)*m.petCellPixelWidth + 3 + pointerY := (y+2)*m.petCellPixelHeight + 5 + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX, pointerY)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX-21, pointerY+11)) + draw := m.petImageDraw(m.transcriptView()) + if draw == nil || draw.X != x-3 || draw.Y != y || draw.OffsetX != 3 || draw.OffsetY != 11 { + t.Fatalf("pixel drag draw = %#v, want cell (%d,%d) with offset (3,11)", draw, x-3, y) + } + + m, cmd, handled = m.handlePetMouse(testMouseRelease(tea.MouseLeft, pointerX-21, pointerY+11)) + if !handled || m.petPixelDrag || m.petDragActive || cmd == nil { + t.Fatal("pixel release should commit the position and restore cell mouse mode") + } + if m.petPositionOffsetX != 3 || m.petPositionOffsetY != 11 { + t.Fatalf("committed pixel offset = (%d,%d), want (3,11)", m.petPositionOffsetX, m.petPositionOffsetY) + } +} + +func TestAmbientPetPixelDragTracksEveryAcceptedEventExactly(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = false + m.petCellPixelWidth = 8 + m.petCellPixelHeight = 16 + x, y := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + pointerX := (x+2)*m.petCellPixelWidth + 3 + pointerY := (y+2)*m.petCellPixelHeight + 5 + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX, pointerY)) + + destinationX, destinationY := pointerX-64, pointerY-32 + m, cmd, _ := m.handlePetMouse(testMouseMotion(tea.MouseLeft, destinationX, destinationY)) + wantX := clampInt(destinationX-m.petDragOffsetPixelX, 0, maxInt(0, (m.width-petImageColumns)*m.petCellPixelWidth)) + wantY := clampInt(destinationY-m.petDragOffsetPixelY, 0, maxInt(0, (m.height-petImageRows)*m.petCellPixelHeight)) + gotX, gotY := m.petDragAbsolutePosition() + if gotX != wantX || gotY != wantY { + t.Fatalf("pet trails accepted pointer event: got (%d,%d), want (%d,%d)", gotX, gotY, wantX, wantY) + } + if cmd == nil { + t.Fatal("direct drag should flush the external pet image even when the text view is unchanged") + } + if !petCommandIncludesRaw(cmd, terminalSyncStart+terminalSyncEnd) { + t.Fatal("direct drag command does not include the external image flush") + } +} + +func TestAmbientPetDragFlushesEquallyBeforeAndAfterGenerationEnds(t *testing.T) { + m := interactivePetTestModel(t) + m.petCellPixelWidth = 8 + m.petCellPixelHeight = 16 + x, y := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + pointerX := (x+2)*m.petCellPixelWidth + 3 + pointerY := (y+2)*m.petCellPixelHeight + 5 + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX, pointerY)) + + m.pending = true + m, activeCmd, _ := m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX-16, pointerY)) + m.pending = false + _, idleCmd, _ := m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX-32, pointerY)) + for state, cmd := range map[string]tea.Cmd{"active": activeCmd, "idle": idleCmd} { + if cmd == nil { + t.Fatalf("%s drag did not request an external image flush", state) + } + if !petCommandIncludesRaw(cmd, terminalSyncStart+terminalSyncEnd) { + t.Fatalf("%s drag command does not include the external image flush", state) + } + } +} + +func petCommandIncludesRaw(cmd tea.Cmd, want any) bool { + if cmd == nil { + return false + } + result := make(chan tea.Msg, 1) + go func() { result <- cmd() }() + select { + case msg := <-result: + if raw, ok := msg.(tea.RawMsg); ok { + return raw.Msg == want + } + if batch, ok := msg.(tea.BatchMsg); ok { + for _, child := range batch { + if petCommandIncludesRaw(child, want) { + return true + } + } + } + case <-time.After(100 * time.Millisecond): + // Animation ticks are deliberately delayed and are not raw terminal + // commands, so do not wait for them while inspecting a batch. + } + return false +} + +func TestSchedulePetPreviewWithoutPicker(t *testing.T) { + m := interactivePetTestModel(t) + m.picker = nil + m.petPreviewLoading = true + m.petPreviewSlug = "stale" + next, cmd := m.schedulePetPreview() + if cmd != nil { + t.Fatal("schedulePetPreview scheduled work without a picker") + } + if next.petPreviewLoading || next.petPreviewSlug != "" { + t.Fatalf("stale preview state was retained: loading=%v slug=%q", next.petPreviewLoading, next.petPreviewSlug) + } +} + +func TestAmbientPetPixelDragReleaseCommitsWithoutGap(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = false + m.petCellPixelWidth = 8 + m.petCellPixelHeight = 16 + x, y := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + pointerX := (x+2)*m.petCellPixelWidth + 3 + pointerY := (y+2)*m.petCellPixelHeight + 5 + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX, pointerY)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX-53, pointerY-27)) + wantX, wantY := m.petDragAbsolutePosition() + + m, cmd, handled := m.handlePetMouse(testMouseRelease(tea.MouseLeft, pointerX-53, pointerY-27)) + if !handled || m.petDragActive || !m.petPositionSet { + t.Fatal("release should immediately commit the exact direct-drag position") + } + if cmdIncludesClearScreen(cmd) { + t.Fatal("committing a direct drag must not flash a full-screen clear") + } + gotX := m.petPositionX*m.petCellPixelWidth + m.petPositionOffsetX + gotY := m.petPositionY*m.petCellPixelHeight + m.petPositionOffsetY + if gotX != wantX || gotY != wantY { + t.Fatalf("released pet = (%d,%d), want pointer destination (%d,%d)", gotX, gotY, wantX, wantY) + } +} + +func TestAmbientPetPixelDragFallsBackBeforeTerminalBoundary(t *testing.T) { + m := interactivePetTestModel(t) + m.petCellPixelWidth = 8 + m.petCellPixelHeight = 16 + x, y := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + pointerX := (x+2)*m.petCellPixelWidth + 3 + pointerY := (y+2)*m.petCellPixelHeight + 5 + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, pointerX, pointerY)) + m, cmd, handled := m.handlePetMouse(testMouseMotion(tea.MouseLeft, m.petCellPixelWidth, pointerY)) + if !handled || !m.petDragActive || m.petPixelDrag || cmd == nil { + t.Fatal("approaching the terminal edge should keep dragging but leave pixel mouse mode") + } + want := ansi.ResetModeMouseExtSgrPixel + ansi.SetModeMouseExtSgr + terminalSyncStart + terminalSyncEnd + if !petCommandIncludesRaw(cmd, want) { + t.Fatalf("edge fallback command did not include %q", want) + } +} + +func TestAmbientPetDragDropsMouseReportFragmentsInsteadOfTypingThem(t *testing.T) { + m := interactivePetTestModel(t) + m.input.SetValue("safe") + m.petDragActive = true + + next, cmd := m.updateModel(testKey('1')) + m = next.(model) + if got := m.input.Value(); got != "safe" { + t.Fatalf("drag-time key fragment changed composer to %q", got) + } + if cmd != nil || !m.petDragActive { + t.Fatal("a leaked mouse fragment should be ignored without ending the drag") + } +} + +func TestPetPixelDragCellSizeAndEscapeRestoreNormalMouseMode(t *testing.T) { + m := interactivePetTestModel(t) + next, _ := m.updateModel(uv.CellSizeEvent{Width: 9, Height: 18}) + m = next.(model) + if m.petCellPixelWidth != 9 || m.petCellPixelHeight != 18 { + t.Fatalf("cell pixels = (%d,%d), want (9,18)", m.petCellPixelWidth, m.petCellPixelHeight) + } + m.petDragActive = true + m.petPixelDrag = true + m.lastKeyTime = time.Now() + m.burstCount = 4 + next, cmd := m.updateModel(testKey(tea.KeyEsc)) + m = next.(model) + if m.petDragActive || m.petPixelDrag || cmd == nil { + t.Fatal("Escape should cancel pixel drag and return a mouse-mode restore command") + } + raw, ok := cmd().(tea.RawMsg) + want := ansi.ResetModeMouseExtSgrPixel + ansi.SetModeMouseExtSgr + if !ok || raw.Msg != want { + t.Fatalf("pixel mouse restore command = %#v, want %q", raw, want) + } + if !m.lastKeyTime.IsZero() || m.burstCount != 0 { + t.Fatalf("cancelled drag retained paste-burst state: time=%s count=%d", m.lastKeyTime, m.burstCount) + } +} + +func TestPetNonPixelDragCancelsOnTerminalBlur(t *testing.T) { + m := interactivePetTestModel(t) + m.petDragActive = true + m.petPixelDrag = false + m.petDragMoved = true + m.petDragState = terminalpet.Running + m.lastKeyTime = time.Now() + m.burstCount = 3 + + next, cmd := m.updateModel(tea.BlurMsg{}) + m = next.(model) + if m.petDragActive || m.petDragMoved || m.petDragState != terminalpet.Idle { + t.Fatalf("blur left non-pixel drag active: active=%t moved=%t state=%q", m.petDragActive, m.petDragMoved, m.petDragState) + } + if cmd != nil { + t.Fatal("non-pixel drag cancellation should not emit a pixel mouse command") + } + if !m.lastKeyTime.IsZero() || m.burstCount != 0 { + t.Fatalf("blur retained paste-burst state: time=%s count=%d", m.lastKeyTime, m.burstCount) + } +} + +func TestDraggedKittyPetKeepsBackgroundContentForAlphaOverlay(t *testing.T) { + m := interactivePetTestModel(t) + m.petPositionSet = true + m.petPositionX, m.petPositionY = 3, 1 + lines := []string{ + "abcdefghijklmnopqrstuvwxyz", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "01234567890123456789012345", + "content remains behind pet", + "transparent pixels show it", + "last line stays unchanged!", + } + + got := m.reservePetImageSlot(lines, 26) + if !reflect.DeepEqual(got, lines) { + t.Fatalf("Kitty alpha overlay changed background rows:\ngot %#v\nwant %#v", got, lines) + } + + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolSixel}) + got = m.reservePetImageSlot(lines, 26) + if reflect.DeepEqual(got, lines) { + t.Fatal("Sixel placement should retain a cleared fallback region") + } +} + +func TestAmbientPetSingleClickWavesAndDoubleClickJumps(t *testing.T) { + m := interactivePetTestModel(t) + now := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + m.now = func() time.Time { return now } + x, y := m.ambientPetPosition(m.width, m.height) + + click := func(current model) model { + next, _, handled := current.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + if !handled { + t.Fatal("pet press was not handled") + } + next, _, handled = next.handlePetMouse(testMouseRelease(tea.MouseLeft, x+2, y+2)) + if !handled { + t.Fatal("pet release was not handled") + } + return next + } + + m = click(m) + if got := m.petState(); got != terminalpet.Waving { + t.Fatalf("single click state = %q, want waving", got) + } + if m.petPhase != 1 { + t.Fatalf("single click phase = %d, want first visibly active waving frame", m.petPhase) + } + now = now.Add(200 * time.Millisecond) + m = click(m) + if got := m.petState(); got != terminalpet.Jumping { + t.Fatalf("double click state = %q, want jumping", got) + } +} + +func TestAmbientPetClickRestartsAnimationTimerImmediately(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = false + m.petTickSeq = 7 + x, y := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + next, cmd, handled := m.handlePetMouse(testMouseRelease(tea.MouseLeft, x+2, y+2)) + if !handled { + t.Fatal("pet release was not handled") + } + if next.petTickSeq != 8 || cmd == nil { + t.Fatalf("click restart = seq:%d cmd:%v, want seq 8 and a fresh frame timer", next.petTickSeq, cmd) + } +} + +func TestAmbientPetIgnoresTicksFromReplacedAnimationLoops(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = false + m.petTickSeq = 7 + m.petPhase = 3 + m.petPlaybackState = terminalpet.Idle + + updated, cmd := m.Update(petTickMsg{seq: 6}) + stale := updated.(model) + if stale.petPhase != 3 || cmd != nil { + t.Fatalf("stale tick advanced phase or rescheduled: phase=%d cmd=%v", stale.petPhase, cmd) + } + + updated, cmd = stale.Update(petTickMsg{seq: 7}) + current := updated.(model) + if current.petPhase != 4 || cmd == nil { + t.Fatalf("current tick phase=%d cmd=%v, want phase 4 and a reschedule", current.petPhase, cmd) + } +} + +func TestAmbientPetRendersFirstFrameImmediatelyWhenPlaybackStateChanges(t *testing.T) { + m := interactivePetTestModel(t) + m.petPhase = 5 + m.petPlaybackState = terminalpet.Idle + m.pending = true + + draw := m.petImageDraw(m.transcriptView()) + if draw == nil { + t.Fatal("working pet has no image draw") + } + if draw.State != terminalpet.Running || draw.Phase != 0 { + t.Fatalf("working draw = state:%q phase:%d, want running phase zero", draw.State, draw.Phase) + } +} + +func TestAmbientPetKeepsLongActionAliveForItsFullPrimaryAnimation(t *testing.T) { + m := interactivePetTestModel(t) + atlas := image.NewNRGBA(image.Rect(0, 0, 192, 26*9)) + animation, err := terminalpet.AtlasAnimation(atlas, 1) + if err != nil { + t.Fatal(err) + } + m.petAnimation = animation + started := time.Now() + m.now = func() time.Time { return started.Add(2300 * time.Millisecond) } + m.petOutcome = terminalpet.Jumping + m.petOutcomeAt = started + + if got := m.petState(); got != terminalpet.Jumping { + t.Fatalf("state after fixed 2.2s hold = %q, want jumping until its 2.52s sequence completes", got) + } + m.now = func() time.Time { return started.Add(2600 * time.Millisecond) } + if got := m.petState(); got != terminalpet.Idle { + t.Fatalf("state after full jumping sequence = %q, want idle", got) + } +} + +func TestAmbientPetDragUsesAdvancingDirectionalAnimationPhase(t *testing.T) { + m := interactivePetTestModel(t) + m.petDragActive = true + m.petDragState = terminalpet.MoveRight + m.petPlaybackState = terminalpet.MoveRight + m.petPhase = 3 + + draw := m.petImageDraw(m.transcriptView()) + if draw == nil { + t.Fatal("dragging pet has no image draw") + } + if draw.State != terminalpet.MoveRight || draw.Phase != 3 { + t.Fatalf("drag draw = state:%q phase:%d, want running-right phase 3", draw.State, draw.Phase) + } +} + +func TestAmbientPetFirstDragMovementRestartsDirectionalTicker(t *testing.T) { + m := interactivePetTestModel(t) + m.reducedMotion = false + m.petTickSeq = 7 + m.petPhase = 4 + m.petPlaybackState = terminalpet.Idle + x, y := m.ambientPetPosition(m.width, m.height) + + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + m, cmd, handled := m.handlePetMouse(testMouseMotion(tea.MouseLeft, x, y+2)) + if !handled { + t.Fatal("first drag movement was not handled") + } + if m.petDragState != terminalpet.MoveLeft || m.petPlaybackState != terminalpet.MoveLeft || + m.petPhase != 0 || m.petTickSeq != 8 || cmd == nil { + t.Fatalf("first movement = drag:%q playback:%q phase:%d seq:%d cmd:%v", + m.petDragState, m.petPlaybackState, m.petPhase, m.petTickSeq, cmd) + } +} + +func TestAmbientPetVerticalDragUsesRunningAnimation(t *testing.T) { + m := interactivePetTestModel(t) + x, y := m.ambientPetPosition(m.width, m.height) + m, _, _ = m.handlePetMouse(testMouseClick(tea.MouseLeft, x+2, y+2)) + m, _, _ = m.handlePetMouse(testMouseMotion(tea.MouseLeft, x+2, y-2)) + if m.petDragState != terminalpet.MoveRight { + t.Fatalf("vertical drag state = %q, want running-right fallback", m.petDragState) + } +} + +func TestAmbientPetMouseLeavesOutsideClicksUntouched(t *testing.T) { + m := interactivePetTestModel(t) + if _, _, handled := m.handlePetMouse(testMouseClick(tea.MouseLeft, 0, 0)); handled { + t.Fatal("click outside the pet should remain available to the normal mouse pipeline") + } +} + +func interactivePetTestModel(t *testing.T) model { + t.Helper() + m := mouseTestModel() + m.width, m.height = 110, 34 + m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowUser, text: "hello"}) + frame := image.NewNRGBA(image.Rect(0, 0, 12, 12)) + m.petAnimation, _ = terminalpet.ThumbnailAnimation(frame) + m.petID = "boba" + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Protocol: terminalpet.ImageProtocolKitty}) + return m +} + +func TestPetsCommandExplainsUnsupportedTerminal(t *testing.T) { + m := newModel(context.Background(), Options{}) + m.petClient = terminalpet.NewClient(t.TempDir()) + m.petRenderer = terminalpet.NewImageRenderer(terminalpet.ImageSupport{Reason: "Terminal companions need Kitty graphics or Sixel image support."}) + next, cmd := m.handlePetsCommand("") + if cmd != nil { + t.Fatal("unsupported terminal should not start a catalog request") + } + nextModel := next.(model) + if nextModel.picker != nil { + t.Fatal("unsupported terminal should not open the pet picker") + } + if got := plainRender(t, nextModel.View().Content); !strings.Contains(got, "Kitty graphics or Sixel") { + t.Fatalf("unsupported-terminal guidance is missing: %q", got) + } +} diff --git a/internal/tui/picker.go b/internal/tui/picker.go index 1e7580bbf..3efb7ced8 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -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 } diff --git a/internal/tui/run.go b/internal/tui/run.go index ea995680e..e88c99936 100644 --- a/internal/tui/run.go +++ b/internal/tui/run.go @@ -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 } diff --git a/internal/tui/run_test.go b/internal/tui/run_test.go index 102a7d039..66cfbd4f2 100644 --- a/internal/tui/run_test.go +++ b/internal/tui/run_test.go @@ -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 diff --git a/internal/tui/sidebar_test.go b/internal/tui/sidebar_test.go index 2c0d15cc7..937532cf0 100644 --- a/internal/tui/sidebar_test.go +++ b/internal/tui/sidebar_test.go @@ -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"), "") diff --git a/internal/tui/view.go b/internal/tui/view.go index cc9e2c242..490b86ae8 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -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