Skip to content

Add interactive terminal companions - #887

Merged
kevincodex1 merged 9 commits into
mainfrom
feat/draggable-terminal-pets
Aug 11, 2026
Merged

Add interactive terminal companions#887
kevincodex1 merged 9 commits into
mainfrom
feat/draggable-terminal-pets

Conversation

@anandh8x

@anandh8x anandh8x commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add /pets companion discovery, preview, installation, selection, and disable controls
  • render metadata-driven animated companions with responsive docking, drag placement, click interactions, and resize-safe positioning
  • preserve terminal output synchronization, sidebar/composer layout, configuration data, and cross-platform mouse behavior
  • tolerate malformed remote catalog entries while retaining valid companions

Why

Terminal companions add an optional, expressive layer to the Zero interface without affecting users who leave the feature disabled. The implementation supports terminal-native image protocols, persists the selected companion, and keeps the experience usable across compact layouts and active agent workflows.

Validation

  • make fmt-check
  • go vet ./...
  • go test -race ./internal/config ./internal/terminalpet ./internal/tui
  • go test ./...
  • GOFLAGS=-buildvcs=false go run ./cmd/zero-release build
  • GOFLAGS=-buildvcs=false go run ./cmd/zero-release smoke
  • make lint-static
  • make vulncheck
  • git diff HEAD --check

Summary by CodeRabbit

  • New Features

    • Added terminal companions with catalog browsing, previews, installation, disabling, animations, and offline access.
    • Added /pets and /pet commands for selecting or hiding companions.
    • Added Kitty, Sixel, and iTerm2 rendering support.
    • Companions can appear beside the composer or transcript, be dragged, docked, and interact with clicks.
    • Saved companion preferences persist across sessions.
  • Bug Fixes

    • Improved picker interactions, previews, resizing, and terminal cleanup.
    • Preserved existing configuration settings when changing companion preferences.
    • Added safer handling for unsupported terminals and invalid companion assets.

@anandh8x
anandh8x force-pushed the feat/draggable-terminal-pets branch from 7bf583f to da2768d Compare August 10, 2026 10:25
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This change adds terminal pets with persisted selection, remote catalog and installation support, animation metadata, Kitty and Sixel rendering, synchronized output handling, and TUI picker, preview, dragging, layout, and playback integration.

Changes

Terminal pet feature

Layer / File(s) Summary
Pet preference persistence
go.mod, internal/config/..., internal/cli/app.go, internal/tui/options.go
The configuration stores and resolves preferences.pet. SetPet preserves unrelated configuration data and writes changes atomically.
Catalog and animation model
internal/terminalpet/client.go, internal/terminalpet/model.go, internal/terminalpet/sixel.go, internal/terminalpet/*_test.go
The client validates catalogs and trusted assets, supports ranking, previews, installation, caching, offline loading, metadata-driven animation, and Sixel encoding.
Terminal image rendering
internal/terminalpet/image_renderer.go, internal/terminalpet/*_test.go
The renderer detects Kitty and Sixel support, caches frames, emits protocol-specific output, updates placements, and clears images.
TUI pet lifecycle and layout
internal/tui/model.go, internal/tui/pets.go, internal/tui/view.go, internal/tui/sidebar_test.go, internal/tui/pets_test.go
The TUI loads pets, opens the picker, previews and installs entries, reserves layout space, schedules animation, and records run outcomes.
Pet picker and interactions
internal/tui/clipboard.go, internal/tui/commands.go, internal/tui/picker.go, internal/tui/modal_selection.go, internal/tui/mouse.go, internal/tui/mouse_filter.go, internal/tui/*_test.go
Pet commands, picker previews, paste filtering, mouse selection, dragging, click animations, and unthrottled drag motion are integrated.
Terminal output integration
internal/tui/pet_output.go, internal/tui/run.go, internal/tui/pet_output_test.go, internal/tui/run_test.go
Bubble Tea output uses synchronized pet-aware writes. Screen transitions clear or invalidate images, and shutdown performs renderer cleanup.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested reviewers: gnanam1990, kevincodex1, vasanthdev2004

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the pull request's primary change: adding interactive terminal companions.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/draggable-terminal-pets

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (18)
internal/tui/run.go (1)

83-94: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a table test for terminalPetFrameCache.

internal/tui/run_test.go exists but the diff adds no coverage for this function. Cover an absolute UserConfigPath, a relative UserConfigPath, a whitespace-only UserConfigPath, and the os.UserConfigDir() fallback. Compare results with filepath.Join rather than hardcoded separators so the test passes on Windows.

The coding guidelines require a regression test for every behavior change, and require canonicalizing paths before comparison.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/run.go` around lines 83 - 94, Add a table-driven test for
terminalPetFrameCache in run_test.go covering absolute, relative, and
whitespace-only UserConfigPath values plus the os.UserConfigDir fallback. Build
expected paths with filepath.Join and canonicalize paths before comparison,
while preserving platform-independent assertions.

Source: Coding guidelines

internal/terminalpet/image_renderer.go (4)

368-380: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Guard the aspect-ratio division against a zero-height bound.

bounds.Dy() is the divisor at Line 376. frame.Bounds().Empty() at Line 369 returns true when either dimension is zero, so the current call path is safe. The guard depends on that coupling. State it, or compute the ratio only when bounds.Dy() > 0.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/image_renderer.go` around lines 368 - 380, Make the
zero-height precondition explicit in renderSixel before calculating widthPixels:
derive bounds and validate bounds.Dy() > 0 before using it as the divisor, while
preserving the existing invalid-frame error and scaling behavior.

338-366: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

len(parts) == 0 is dead code.

strings.Split always returns at least one element. The len(parts) == 0 test at Line 340 never evaluates true. Keep only the upper bound.

🧹 Proposed cleanup
-	if len(parts) == 0 || len(parts) > 3 {
+	if len(parts) > 3 {
 		return false
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/image_renderer.go` around lines 338 - 366, Remove the
unreachable len(parts) == 0 condition from the validation in
dottedVersionAtLeast, keeping only the len(parts) > 3 upper-bound check and
preserving the remaining version parsing behavior.

314-336: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The frame cache grows without bound.

cachePNGFrame writes one file per unique frame digest and never removes anything. Each installed pet, each animation state, and each phase adds a file under ~/.config/zero/pets/frame-cache. The directory only grows across sessions. Add a size or age budget, or prune the directory at startup.

This applies only to the iTerm2 local-file protocol path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/image_renderer.go` around lines 314 - 336, Update
cachePNGFrame to enforce a bounded frame-cache policy for the iTerm2 local-file
protocol path, pruning cached PNGs by age or total size before or after writing
the new digest file. Preserve current digest-based reuse and error propagation,
and limit cleanup to the configured terminal pet frame-cache directory.

189-216: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Restore the cursor when the protocol switch takes the default branch.

Line 189 writes \x1b[s and a cursor-position sequence before the switch. The default branch returns at Line 215 without writing \x1b[u. The saved cursor is then never restored, and the cursor stays at the pet position. The branch is unreachable today because Supported() only admits the three handled protocols. A new protocol value would make it reachable.

🛠️ Proposed fix
 	default:
+		if _, err := io.WriteString(writer, "\x1b[u"); err != nil {
+			return err
+		}
 		return nil
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/image_renderer.go` around lines 189 - 216, Update the
default branch of the protocol switch in the renderer’s frame-writing method to
restore the previously saved cursor with the matching restore sequence before
returning. Leave the existing handling for ImageProtocolKitty,
ImageProtocolKittyLocalFile, and ImageProtocolSixel unchanged.
internal/tui/pet_output_test.go (2)

44-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The Seek call before os.ReadFile has no effect.

Line 44 moves the file offset. Line 47 calls os.ReadFile(file.Name()), which opens a separate handle and ignores that offset. Remove the Seek.

Elsewhere in this file, Lines 150, 197, 236, and 308 use file.Seek(0, 1) to capture the current offset. That use is correct, but io.SeekCurrent reads better than the literal 1.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/pet_output_test.go` around lines 44 - 50, Remove the redundant
file.Seek call immediately before os.ReadFile in the affected test. In the other
offset-capture calls in this file, replace the literal whence value 1 with
io.SeekCurrent while preserving their existing behavior.

17-27: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a concurrency test for petImageOutput.

petImageOutput holds mu to serialize Write and clearImage. No test drives both concurrently, so the race detector never exercises that guard. Bubble Tea writes frames from its render goroutine while internal/tui/run.go Line 69 calls clearImage on the main goroutine.

Add a test that runs concurrent Write calls against a clearImage call and run it with -race.

The coding guidelines require running affected concurrent code under the race detector.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tui/pet_output_test.go` around lines 17 - 27, Extend the tests
around petImageOutput with a concurrency case that executes concurrent Write
calls while clearImage runs, exercising the mu synchronization between them.
Ensure the test is race-detector compatible and validate the affected package
with go test -race.

Source: Coding guidelines

internal/terminalpet/image_renderer_test.go (1)

142-167: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add failure-path coverage for the local-file cache.

cachePNGFrame returns "terminal pet image cache is unavailable" when cacheDir is empty. internal/tui/run.go Line 93 can return an empty cache path when no config root resolves, and Run then constructs the renderer with that empty path. On iTerm2 that combination makes every Render call return an error, which petImageOutput.Write propagates to Bubble Tea as a write failure.

Add a test that builds NewImageRenderer(ImageSupport{Protocol: ImageProtocolKittyLocalFile}) with no cache and asserts the error. Also add a Zellij detection case and a malformed TERM_PROGRAM_VERSION case.

The coding guidelines require a regression test for every behavior or security-boundary change, including failure paths.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/image_renderer_test.go` around lines 142 - 167, Extend
the image renderer tests with a local-file failure case using NewImageRenderer
and ImageProtocolKittyLocalFile without a cache directory, asserting Render
returns the “terminal pet image cache is unavailable” error. Also add coverage
for Zellij detection and malformed TERM_PROGRAM_VERSION handling in the relevant
terminal-detection tests, preserving existing behavior for valid detection
inputs.

Source: Coding guidelines

internal/terminalpet/sixel.go (2)

12-48: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Sixel frames are re-quantized and re-encoded on every render.

The Kitty path caches PNG bytes in Animation.pngCache (see internal/terminalpet/model.go Lines 56-63). The Sixel path has no equivalent. renderSixel in internal/terminalpet/image_renderer.go Lines 368-380 runs a Catmull-Rom rescale, and encodeSixel then walks every pixel twice and allocates a []int16 buffer plus a map, on every animation tick.

Add a cache keyed by frameCacheKey plus the target height, in the same style as pngCache.

The static analysis narrowing warnings on Lines 29, 30, 56, and 88 are false positives. red>>13 and green>>13 yield 0-7, and blue>>14 yields 0-3, so the packed value always fits in uint8, and the palette index always fits in int16.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/sixel.go` around lines 12 - 48, Add Sixel frame caching
keyed by frameCacheKey and target height, matching the existing
Animation.pngCache pattern used by the Kitty renderer. Update renderSixel and
the Animation cache state so repeated renders reuse encoded Sixel bytes instead
of rescaling and calling encodeSixel each tick; retain encodeSixel’s current
quantization and ignore the noted narrowing warnings.

Source: Linters/SAST tools


49-75: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

No run-length encoding makes each Sixel frame large.

The band loop emits one byte per pixel column per active color. For a 100-pixel-wide frame with 40 active colors in a band, one band costs about 4 KB, and a 13-band frame costs about 52 KB. The encoder writes that on every animation tick to the terminal.

Sixel supports the !<count> repeat introducer. Collapse runs of identical mask bytes to cut the payload by a large factor.

⚡ Sketch of the repeat introducer
-			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)
-			}
+			run, runLength := byte(0), 0
+			flush := func() {
+				switch {
+				case runLength == 0:
+				case runLength < 4:
+					output.WriteString(strings.Repeat(string(rune('?'+run)), runLength))
+				default:
+					fmt.Fprintf(&output, "!%d%c", runLength, '?'+run)
+				}
+			}
+			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
+					}
+				}
+				if runLength > 0 && mask == run {
+					runLength++
+					continue
+				}
+				flush()
+				run, runLength = mask, 1
+			}
+			flush()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/sixel.go` around lines 49 - 75, Update the Sixel
emission loop in the band encoder to run-length encode consecutive identical
mask bytes using Sixel’s !<count> repeat introducer, while preserving the
existing color and band delimiters. Ensure each run is emitted with the correct
count and mask byte, including single-byte runs, and retain the current output
behavior when masks differ.
internal/terminalpet/client.go (4)

487-493: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The value closure silently reads column 0 for any field absent from wanted.

index := wanted[field] returns 0 when field is not a manifest key. The closure then decodes row[0], which is the slug column, into the target.

This is safe today. Every field passed to value appears in required and is checked at lines 473-477. The hazard is future edits: adding an optional field read through value would silently decode the slug into it, with no error.

Make the lookup explicit.

🛡️ Proposed guard
 			value := func(field string, target any) error {
-				index := wanted[field]
-				if index >= len(row) {
+				index, ok := wanted[field]
+				if !ok {
+					return fmt.Errorf("pet catalog has no %q field", field)
+				}
+				if index >= len(row) {
 					return fmt.Errorf("pet catalog row is missing %q", field)
 				}
 				return json.Unmarshal(row[index], target)
 			}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/client.go` around lines 487 - 493, Update the value
closure’s wanted lookup to explicitly check whether field exists in wanted
before indexing the row. Return the existing missing-column error when the
manifest key is absent, while preserving the bounds check and JSON unmarshalling
for valid fields.

319-332: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

LoadInstalled skips the sprite-version validation that Install applies.

Install validates the metadata sprite version at lines 247-252 and rejects anything other than 1 or 2. LoadInstalled at lines 323-325 assigns document.SpriteVersion with no equivalent check.

The failure is contained: atlasAnimation rejects unknown versions at internal/terminalpet/model.go line 261. So a tampered installed pet.json fails closed. The user sees "unsupported sprite version" instead of the more specific "invalid pet metadata" message.

Align the two paths so the validation rule lives in one place.

♻️ Proposed alignment
 		if document.SpriteVersion != 0 {
+			if document.SpriteVersion != 1 && document.SpriteVersion != 2 {
+				return nil, fmt.Errorf("read installed pet metadata: unsupported sprite version %d", document.SpriteVersion)
+			}
 			entry.SpriteVersion = document.SpriteVersion
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/client.go` around lines 319 - 332, Centralize the
sprite-version validation currently applied by Install and reuse it from
LoadInstalled before assigning document.SpriteVersion or calling
document.atlasTracks. Preserve the rule that only versions 1 and 2 are valid,
and return the existing invalid-pet-metadata error for unsupported versions
instead of relying on atlasAnimation to reject them.

771-782: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Windows reserved device names pass validateSlug and become directory paths.

The character class allows [a-z0-9] plus - and _. Names such as con, prn, aux, nul, com1 through com9, and lpt1 through lpt9 all pass. Line 293 and line 304 pass the slug to filepath.Join under the installed directory.

On Windows those names resolve to devices, not files. os.MkdirAll fails, so Install returns an error rather than corrupting state. The behavior is fail-closed but the error text is confusing, and a remote catalog controls the slug.

The coding guidelines require code to pass on Linux, macOS, and Windows. Add a reserved-name rejection and a test case.

🛡️ Proposed guard and test
+// Windows resolves these names to devices regardless of directory, so they can
+// never back an installed pet directory. Rejected on every platform to keep the
+// installed layout identical across operating systems.
+var reservedSlugs = map[string]bool{
+	"con": true, "prn": true, "aux": true, "nul": true,
+	"com1": true, "com2": true, "com3": true, "com4": true, "com5": true,
+	"com6": true, "com7": true, "com8": true, "com9": true,
+	"lpt1": true, "lpt2": true, "lpt3": true, "lpt4": true, "lpt5": true,
+	"lpt6": true, "lpt7": true, "lpt8": true, "lpt9": true,
+}
+
 func validateSlug(slug string) error {
 	if slug == "" || len(slug) > 128 {
 		return fmt.Errorf("invalid pet slug %q", slug)
 	}
+	if reservedSlugs[slug] {
+		return fmt.Errorf("invalid pet slug %q", slug)
+	}
 	for index, value := range slug {

Extend the existing table in internal/terminalpet/client_test.go line 298:

-	for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", ""} {
+	for _, slug := range []string{"../boba", "/boba", "Boba", "boba/other", "", "con", "nul", "lpt1"} {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/client.go` around lines 771 - 782, Update validateSlug
to reject Windows reserved device names, including con, prn, aux, nul,
com1–com9, and lpt1–lpt9, case-insensitively and when followed by a slug
extension or suffix as applicable to Windows path semantics. Preserve existing
character and length validation, and extend the validateSlug tests in
client_test.go with representative reserved-name cases.

Source: Coding guidelines


745-750: 🚀 Performance & Scalability | 🔵 Trivial

The preview cache grows without bound.

cacheFile writes into previewDir() with one entry per (slug, spritesheet URL) pair, keyed by the hash at line 726. Nothing ever removes those files. When an upstream pet republishes a new spritesheet URL, a new cache file appears and the old one stays.

Each file is capped at maxPreviewBytes (2 MiB). The manifest holds up to 60 ranked pets per fetch. Growth is slow but monotonic in a user config directory.

Consider a size or age sweep on the preview directory at startup.

[operational_advice]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/client.go` around lines 745 - 750, Implement bounded
preview-cache cleanup during startup by sweeping the directory returned by
previewDir(), removing stale or excess cache files while preserving current
entries needed by the application. Update the startup initialization path and
reuse existing cache-size or age constants where available; keep cacheFile
focused on atomically writing files.
internal/terminalpet/model.go (1)

365-375: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

cropImage.At does not clamp to bounds.

At adds the offset and reads the source directly. The image.Image contract expects coordinates outside Bounds to return the zero color. Here they sample the neighboring sprite cell instead.

No current consumer reads outside Bounds. png.Encode iterates exactly over Bounds. The gap is a latent trap for any future consumer that pads or over-reads.

♻️ Proposed clamp
 func (c cropImage) At(x, y int) color.Color {
+	if !image.Pt(x, y).In(c.bounds) {
+		return c.source.ColorModel().Convert(color.Alpha{})
+	}
 	return c.source.At(x+c.offset.X, y+c.offset.Y)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/model.go` around lines 365 - 375, Update cropImage.At to
return the image package’s zero color whenever the requested coordinates fall
outside c.bounds; only apply c.offset and sample c.source for coordinates within
bounds. Preserve the existing ColorModel and Bounds behavior.
internal/terminalpet/model_test.go (1)

9-38: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the looping-state case and the ClickAnimation cycling case.

Both tests are correct and the arithmetic checks out. Two documented contracts have no coverage:

  • PrimaryDuration returns 0 when loopStart == 0. The doc comment at internal/terminalpet/model.go lines 103-105 states this explicitly. Only the -1 branch is tested.
  • ClickAnimation cycles with index % len(clickAnimations) and folds negative indices. internal/terminalpet/client_test.go line 131 checks index 0 only, so the wrap is untested.
🧪 Proposed additional tests
func TestPrimaryDurationIsZeroForLoopingState(t *testing.T) {
	looping := State("looping-action")
	animation := &Animation{
		durations:  map[State][]time.Duration{looping: {100 * time.Millisecond, 200 * time.Millisecond}},
		loopStarts: map[State]int{looping: 0},
	}
	if got := animation.PrimaryDuration(looping); got != 0 {
		t.Fatalf("looping duration = %s, want 0", got)
	}
	if got := animation.PrimaryDuration(State("absent")); got != 0 {
		t.Fatalf("absent-state duration = %s, want 0", got)
	}
}

func TestClickAnimationCyclesAndFoldsNegativeIndex(t *testing.T) {
	first, second := State("first"), State("second")
	animation := &Animation{clickAnimations: []State{first, second}}

	for index, want := range map[int]State{0: first, 1: second, 2: first, 3: second, -1: second} {
		got, ok := animation.ClickAnimation(index)
		if !ok || got != want {
			t.Errorf("ClickAnimation(%d) = %q, %t; want %q, true", index, got, ok, want)
		}
	}

	empty := &Animation{}
	if got, ok := empty.ClickAnimation(0); ok || got != "" {
		t.Fatalf("empty ClickAnimation = %q, %t; want \"\", false", got, ok)
	}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/model_test.go` around lines 9 - 38, Add coverage for the
documented looping and cycling contracts: in the model tests, verify
PrimaryDuration returns zero for a state with loopStarts set to 0 and for an
absent state; add ClickAnimation tests covering repeated indices, negative-index
folding, and an empty clickAnimations list returning no result. Use the existing
Animation, PrimaryDuration, and ClickAnimation symbols without changing
implementation behavior.
internal/terminalpet/client_test.go (1)

248-278: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

rankingFails is shared between the test goroutine and the server handler goroutine.

Line 276 writes rankingFails from the test goroutine. Line 256 reads it from the handler goroutine. The write is ordered before the second Catalog call, so the common path is safe.

One window is not ordered. Catalog gives the ranking fetch a 1500 ms sub-context and calls cancel() at internal/terminalpet/client.go line 108. If that fetch times out, the handler goroutine from the first Catalog call can still be running when line 276 writes. The race detector can then report the access.

The coding guidelines require affected concurrent code to run under the race detector. Use an atomic to remove the window.

♻️ Proposed fix
-	var rankingFails bool
+	var rankingFails atomic.Bool
 	var server *httptest.Server
 	server = httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
 		switch request.URL.Path {
 		case "/manifest":
 			manifest.AssetBase = server.URL
 			_ = json.NewEncoder(writer).Encode(manifest)
 		case "/ranking":
-			if rankingFails {
+			if rankingFails.Load() {
 				http.Error(writer, "unavailable", http.StatusServiceUnavailable)
 				return
 			}
-	rankingFails = true
+	rankingFails.Store(true)

Add "sync/atomic" to the import block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/terminalpet/client_test.go` around lines 248 - 278, Replace the
shared rankingFails boolean with a sync/atomic boolean in this test. Update the
/ranking handler to atomically load its value and the test’s failure setup to
atomically store it, eliminating the race between Catalog cancellation and the
handler goroutine.

Source: Coding guidelines

internal/config/writer.go (1)

594-647: 🗄️ Data Integrity & Integration | 🔵 Trivial | 🏗️ Heavy lift

SetPet round-trips the config through map[string]json.RawMessage, which both preserves unknown members and discards key order. The map choice is what makes SetPet safer than its siblings and what makes it rewrite the whole file layout. Treat the two effects together when deciding the final shape.

  • internal/config/writer.go#L594-L647: extract the raw read-modify-write into a shared preference setter so SetTheme (line 587) and SetRecapsEnabled (line 565) stop dropping config members the Go structs do not model. Keep the extraction out of this PR if it widens the approved scope.
  • internal/config/writer.go#L639-L642: decide whether alphabetical key reordering is acceptable. If it is, add a test that documents it. If it is not, replace the map with an ordered representation, which also removes the need for the sibling helper to reorder.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/writer.go` around lines 594 - 647, Update
internal/config/writer.go lines 594-647 by extracting SetPet’s raw
read-modify-write logic into a shared preference setter used by SetTheme and
SetRecapsEnabled, preserving unknown config members; if that exceeds the
approved scope, leave the extraction out of this PR. At
internal/config/writer.go lines 639-642, either document the existing
alphabetical key reordering with a test or replace the map-based representation
with an ordered one to preserve key order and eliminate sibling reordering.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/pet_writer_test.go`:
- Around line 10-93: Add regression tests covering all untested SetPet branches:
reject a blank path, reject malformed JSON, create and persist a config when the
file is missing (including nested directories), and verify a blank pet removes
the pet member and deletes an empty preferences object while preserving
unrelated fields.

In `@internal/config/writer.go`:
- Around line 639-642: Update the config serialization flow around
json.MarshalIndent in SetPet to preserve the original top-level key order
instead of encoding raw as a map that alphabetically sorts keys. Decode the
document into an ordered representation, update the pet member in place, and
retain existing formatting and error behavior.

In `@internal/terminalpet/client_test.go`:
- Around line 287-318: Add regression tests for the three untrusted-input
guards: verify decodeImage rejects dimensions beyond maxImageSide or
maxImagePixels, fetch/fetchLimit rejects response bodies exceeding the requested
byte limit, and resolveAssetURL rejects references outside allowed catalog
roots. Reuse existing test helpers and symbols such as encodedPNG, testClient,
maxImageSide, and maxImagePixels where available, and assert each failure path
returns an error.

In `@internal/tui/pet_output_test.go`:
- Around line 35-39: Check and fail the test immediately when
terminalpet.ThumbnailAnimation returns an error instead of discarding it. In
internal/tui/pet_output_test.go, apply this to the calls at lines 37, 71, 100,
119, 142, 189, 229, 264, 298, and 338; in
internal/terminalpet/image_renderer_test.go, apply it at lines 126 and 145,
using t.Fatal with the error details before passing animation to the renderer.

In `@internal/tui/pet_output.go`:
- Around line 96-104: Update the unsynchronized write path around
o.output.Write(value) to detect when written is less than len(value), even if
writeErr is nil, and convert that condition to io.ErrShortWrite before writing
imageUpdate. Match the short-write handling already used in the synchronized
path while preserving the existing error-return ordering.
- Around line 93-104: Update the write path around the existing
terminalSyncStart/terminalSyncEnd handling so values containing an unmatched
terminalSyncStart are treated as already inside a synchronized block: write the
value and append imageUpdate.Bytes() without emitting another terminalSyncStart
or terminalSyncEnd. Preserve the current marker-wrapping behavior for values
without an unmatched start.

In `@internal/tui/pets_test.go`:
- Around line 975-998: Increase the timeout in petCommandIncludesRaw to a
reliable budget below petFrameDelay (180 ms), so immediate raw commands are not
rejected under race-enabled or busy CI scheduling while genuinely delayed
animation ticks still time out.

In `@internal/tui/pets.go`:
- Around line 161-171: Guard m.picker before invoking picker.current() in
schedulePetPreview. If the picker is nil, clear the preview loading state and
slug and return without scheduling a command, matching the existing nil-picker
handling in startPetPreview and pickerMoved.

In `@internal/tui/run.go`:
- Around line 76-79: Update the cleanup error branch in the run flow around
petOutput.clearImage and the clearErr check so it still prints the diagnostic
but returns exit code 0, preserving successful command status after the chat
session completes.
- Around line 83-94: Update terminalPetFrameCache to prevent relative
UserConfigPath values from producing cache paths under the current working
directory. Resolve configPath to an absolute path before taking its directory,
or ignore non-absolute values and fall back to os.UserConfigDir(); preserve the
existing pets/frame-cache layout and empty-root behavior.
- Around line 45-46: Update terminalPetFrameCache to always resolve a usable
non-empty cache root when configured and user cache paths are unavailable, then
adjust petImageOutput.Write to swallow renderer errors and preserve the terminal
output flow so decorative pet rendering cannot fail the TUI.

---

Nitpick comments:
In `@internal/config/writer.go`:
- Around line 594-647: Update internal/config/writer.go lines 594-647 by
extracting SetPet’s raw read-modify-write logic into a shared preference setter
used by SetTheme and SetRecapsEnabled, preserving unknown config members; if
that exceeds the approved scope, leave the extraction out of this PR. At
internal/config/writer.go lines 639-642, either document the existing
alphabetical key reordering with a test or replace the map-based representation
with an ordered one to preserve key order and eliminate sibling reordering.

In `@internal/terminalpet/client_test.go`:
- Around line 248-278: Replace the shared rankingFails boolean with a
sync/atomic boolean in this test. Update the /ranking handler to atomically load
its value and the test’s failure setup to atomically store it, eliminating the
race between Catalog cancellation and the handler goroutine.

In `@internal/terminalpet/client.go`:
- Around line 487-493: Update the value closure’s wanted lookup to explicitly
check whether field exists in wanted before indexing the row. Return the
existing missing-column error when the manifest key is absent, while preserving
the bounds check and JSON unmarshalling for valid fields.
- Around line 319-332: Centralize the sprite-version validation currently
applied by Install and reuse it from LoadInstalled before assigning
document.SpriteVersion or calling document.atlasTracks. Preserve the rule that
only versions 1 and 2 are valid, and return the existing invalid-pet-metadata
error for unsupported versions instead of relying on atlasAnimation to reject
them.
- Around line 771-782: Update validateSlug to reject Windows reserved device
names, including con, prn, aux, nul, com1–com9, and lpt1–lpt9,
case-insensitively and when followed by a slug extension or suffix as applicable
to Windows path semantics. Preserve existing character and length validation,
and extend the validateSlug tests in client_test.go with representative
reserved-name cases.
- Around line 745-750: Implement bounded preview-cache cleanup during startup by
sweeping the directory returned by previewDir(), removing stale or excess cache
files while preserving current entries needed by the application. Update the
startup initialization path and reuse existing cache-size or age constants where
available; keep cacheFile focused on atomically writing files.

In `@internal/terminalpet/image_renderer_test.go`:
- Around line 142-167: Extend the image renderer tests with a local-file failure
case using NewImageRenderer and ImageProtocolKittyLocalFile without a cache
directory, asserting Render returns the “terminal pet image cache is
unavailable” error. Also add coverage for Zellij detection and malformed
TERM_PROGRAM_VERSION handling in the relevant terminal-detection tests,
preserving existing behavior for valid detection inputs.

In `@internal/terminalpet/image_renderer.go`:
- Around line 368-380: Make the zero-height precondition explicit in renderSixel
before calculating widthPixels: derive bounds and validate bounds.Dy() > 0
before using it as the divisor, while preserving the existing invalid-frame
error and scaling behavior.
- Around line 338-366: Remove the unreachable len(parts) == 0 condition from the
validation in dottedVersionAtLeast, keeping only the len(parts) > 3 upper-bound
check and preserving the remaining version parsing behavior.
- Around line 314-336: Update cachePNGFrame to enforce a bounded frame-cache
policy for the iTerm2 local-file protocol path, pruning cached PNGs by age or
total size before or after writing the new digest file. Preserve current
digest-based reuse and error propagation, and limit cleanup to the configured
terminal pet frame-cache directory.
- Around line 189-216: Update the default branch of the protocol switch in the
renderer’s frame-writing method to restore the previously saved cursor with the
matching restore sequence before returning. Leave the existing handling for
ImageProtocolKitty, ImageProtocolKittyLocalFile, and ImageProtocolSixel
unchanged.

In `@internal/terminalpet/model_test.go`:
- Around line 9-38: Add coverage for the documented looping and cycling
contracts: in the model tests, verify PrimaryDuration returns zero for a state
with loopStarts set to 0 and for an absent state; add ClickAnimation tests
covering repeated indices, negative-index folding, and an empty clickAnimations
list returning no result. Use the existing Animation, PrimaryDuration, and
ClickAnimation symbols without changing implementation behavior.

In `@internal/terminalpet/model.go`:
- Around line 365-375: Update cropImage.At to return the image package’s zero
color whenever the requested coordinates fall outside c.bounds; only apply
c.offset and sample c.source for coordinates within bounds. Preserve the
existing ColorModel and Bounds behavior.

In `@internal/terminalpet/sixel.go`:
- Around line 12-48: Add Sixel frame caching keyed by frameCacheKey and target
height, matching the existing Animation.pngCache pattern used by the Kitty
renderer. Update renderSixel and the Animation cache state so repeated renders
reuse encoded Sixel bytes instead of rescaling and calling encodeSixel each
tick; retain encodeSixel’s current quantization and ignore the noted narrowing
warnings.
- Around line 49-75: Update the Sixel emission loop in the band encoder to
run-length encode consecutive identical mask bytes using Sixel’s !<count> repeat
introducer, while preserving the existing color and band delimiters. Ensure each
run is emitted with the correct count and mask byte, including single-byte runs,
and retain the current output behavior when masks differ.

In `@internal/tui/pet_output_test.go`:
- Around line 44-50: Remove the redundant file.Seek call immediately before
os.ReadFile in the affected test. In the other offset-capture calls in this
file, replace the literal whence value 1 with io.SeekCurrent while preserving
their existing behavior.
- Around line 17-27: Extend the tests around petImageOutput with a concurrency
case that executes concurrent Write calls while clearImage runs, exercising the
mu synchronization between them. Ensure the test is race-detector compatible and
validate the affected package with go test -race.

In `@internal/tui/run.go`:
- Around line 83-94: Add a table-driven test for terminalPetFrameCache in
run_test.go covering absolute, relative, and whitespace-only UserConfigPath
values plus the os.UserConfigDir fallback. Build expected paths with
filepath.Join and canonicalize paths before comparison, while preserving
platform-independent assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0f5aa8d8-aa18-42ff-b717-872a4f5b3796

📥 Commits

Reviewing files that changed from the base of the PR and between 2458e0c and 7bf583f.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (30)
  • go.mod
  • internal/cli/app.go
  • internal/config/pet_writer_test.go
  • internal/config/resolver.go
  • internal/config/resolver_test.go
  • internal/config/types.go
  • internal/config/writer.go
  • internal/terminalpet/client.go
  • internal/terminalpet/client_test.go
  • internal/terminalpet/image_renderer.go
  • internal/terminalpet/image_renderer_test.go
  • internal/terminalpet/model.go
  • internal/terminalpet/model_test.go
  • internal/terminalpet/sixel.go
  • internal/tui/clipboard.go
  • internal/tui/commands.go
  • internal/tui/modal_selection.go
  • internal/tui/model.go
  • internal/tui/mouse.go
  • internal/tui/mouse_filter.go
  • internal/tui/mouse_filter_test.go
  • internal/tui/options.go
  • internal/tui/pet_output.go
  • internal/tui/pet_output_test.go
  • internal/tui/pets.go
  • internal/tui/pets_test.go
  • internal/tui/picker.go
  • internal/tui/run.go
  • internal/tui/sidebar_test.go
  • internal/tui/view.go

Comment thread internal/config/pet_writer_test.go
Comment thread internal/config/writer.go Outdated
Comment thread internal/terminalpet/client_test.go
Comment thread internal/tui/pet_output_test.go
Comment thread internal/tui/pet_output.go
Comment thread internal/tui/pets_test.go
Comment thread internal/tui/pets.go
Comment thread internal/tui/run.go
Comment thread internal/tui/run.go
Comment thread internal/tui/run.go
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: d195972df50b
Changed files (35): go.mod, go.sum, internal/cli/app.go, internal/config/json_object_edit.go, internal/config/pet_writer_test.go, internal/config/resolver.go, internal/config/resolver_test.go, internal/config/types.go, internal/config/writer.go, internal/terminalpet/client.go, internal/terminalpet/client_test.go, internal/terminalpet/image_renderer.go, and 23 more

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/pet_writer_test.go`:
- Around line 67-73: Strengthen the persistence assertions in the relevant test
by unmarshalling root["preferences"] and root["future"] into structured values,
then compare the specific pet and enabled fields for exact equality. Replace
substring checks that can match values such as "boba-extra" or nested enabled
fields, while preserving the existing failure reporting and regression coverage.

In `@internal/tui/run_test.go`:
- Around line 28-53: Add regression cases around terminalPetFrameCacheWith for
providers returning an empty string with nil error: verify a blank userConfigDir
falls back to cacheRoot, and verify a blank userCacheDir produces an empty
result. Keep the existing provider-error cases unchanged and use the same
canonical path comparisons and failure assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ede0a90c-2972-44a1-92fb-92159611a193

📥 Commits

Reviewing files that changed from the base of the PR and between da2768d and d0fcb2d.

📒 Files selected for processing (9)
  • internal/config/pet_writer_test.go
  • internal/terminalpet/client_test.go
  • internal/terminalpet/image_renderer_test.go
  • internal/tui/pet_output.go
  • internal/tui/pet_output_test.go
  • internal/tui/pets.go
  • internal/tui/pets_test.go
  • internal/tui/run.go
  • internal/tui/run_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • internal/tui/pet_output.go
  • internal/tui/run.go
  • internal/tui/pets.go
  • internal/terminalpet/client_test.go

Comment thread internal/config/pet_writer_test.go
Comment thread internal/tui/run_test.go
@anandh8x

Copy link
Copy Markdown
Collaborator Author

@coderabbitai re-review the latest commit and approve if everything looks good. All requested changes are addressed and checks are green.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Pull request metadata =='
gh pr view 887 --repo Gitlawb/zero --json number,headRefOid,baseRefOid,latestReviews,statusCheckRollup --jq '{
  number,
  headRefOid,
  baseRefOid,
  latestReviews: [.latestReviews[] | {author: .author.login, state, submittedAt, body}],
  checks: [.statusCheckRollup[] | {name: .name, status: .status, conclusion: .conclusion}]
}'

printf '%s\n' '== Latest commit =='
git show --no-ext-diff --format='commit %H%nparent %P%nsubject %s%n' --stat HEAD
printf '%s\n' '== Latest commit file list =='
git diff-tree --no-commit-id --name-status -r HEAD

printf '%s\n' '== CodeRabbit review comments and unresolved threads =='
gh api graphql -F owner='Gitlawb' -F name='zero' -F number=887 -f query='
query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviewThreads(first: 100) {
        nodes {
          isResolved
          path
          line
          originalLine
          comments(first: 20) {
            nodes {
              author { login }
              body
              createdAt
            }
          }
        }
      }
    }
  }
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]
  | select(any(.comments.nodes[]?; .author.login == "coderabbitai"))
  | {isResolved, path, line, originalLine, comments: [.comments.nodes[] | {author: .author.login, body, createdAt}]}'

printf '%s\n' '== AST outline of latest changed Go files =='
git diff-tree --no-commit-id --name-only -r HEAD -- '*.go' |
while IFS= read -r f; do
  [ -n "$f" ] || continue
  printf '\n-- %s --\n' "$f"
  ast-grep outline "$f" --items all || true
done

printf '%s\n' '== Latest patch =='
git diff --no-ext-diff --unified=60 HEAD^ HEAD -- '*.go' 'go.mod' 'go.sum'

Length of output: 56441


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Writer and cache-helper outlines =='
ast-grep outline internal/config/writer.go --items all
ast-grep outline internal/tui/run.go --items all

printf '%s\n' '== SetPet implementation and surrounding write helpers =='
sed -n '560,680p' internal/config/writer.go

printf '%s\n' '== Frame-cache implementation =='
sed -n '60,165p' internal/tui/run.go

printf '%s\n' '== Complete latest test functions =='
sed -n '1,175p' internal/config/pet_writer_test.go
sed -n '1,110p' internal/tui/run_test.go

printf '%s\n' '== SetPet call sites and config serialization conventions =='
rg -n -C 4 '\bSetPet\s*\(' --type go .
rg -n -C 3 'json\.MarshalIndent\(' --type go internal

printf '%s\n' '== Full PR changes to config writer and associated tests =='
git diff --no-ext-diff --unified=35 2458e0c67b49a2e959e4ec068f1f2ad382cb2406 HEAD -- internal/config/writer.go internal/config/pet_writer_test.go internal/tui/run.go internal/tui/run_test.go

Length of output: 67466


@anandh8x I verified the latest commit and the successful checks.

I cannot approve yet. One valid CodeRabbit thread remains unresolved.

SetPet in internal/config/writer.go Line 639 serializes map[string]json.RawMessage with json.MarshalIndent. This alphabetizes top-level JSON keys and reformats the user configuration file. Selecting or disabling a pet can therefore create a whole-file diff instead of changing only preferences.pet.

Please preserve the original JSON member order, or explicitly document and test the normalization behavior.

You are interacting with an AI system.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/json_object_edit.go`:
- Around line 261-265: Update insertJSONMember’s empty-object branch to preserve
the existing trailing newline and indentation when object contains multiline
whitespace, keeping the closing brace on its original line while inserting the
entry. Add a byte-for-byte regression test through SetPet using a multiline
empty preferences object and verify the resulting JSON formatting.
- Around line 46-48: Update the null-handling branch in SetPet to check for an
earlier duplicate preferences member using the same protection logic as the
existing lines 90-94 before calling removeJSONMember; retain the final
preferences member when such a duplicate exists. Add a regression test that runs
SetPet(path, "") on duplicate preferences input, reloads the file, and verifies
the earlier pet value is not reactivated.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b3b3e59c-2c76-420a-b861-df68e7de5394

📥 Commits

Reviewing files that changed from the base of the PR and between 581f29b and 3c784e7.

📒 Files selected for processing (3)
  • internal/config/json_object_edit.go
  • internal/config/pet_writer_test.go
  • internal/config/writer.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/config/writer.go

Comment thread internal/config/json_object_edit.go
Comment thread internal/config/json_object_edit.go
@anandh8x

Copy link
Copy Markdown
Collaborator Author

@coderabbitai all requested changes are resolved, all review threads are closed, and every check is green. Please review the latest commit and update your formal review to approve

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

repo="Gitlawb/zero"
pr="887"

printf '%s\n' '== Pull request state and checks =='
gh pr view "$pr" --repo "$repo" \
  --json number,headRefOid,baseRefOid,latestReviews,statusCheckRollup \
  --jq '{
    number,
    headRefOid,
    baseRefOid,
    latestReviews: [.latestReviews[] | {author: .author.login, state, submittedAt}],
    checks: [.statusCheckRollup[] | {name: .name, status: .status, conclusion: .conclusion}]
  }'

printf '%s\n' '== CodeRabbit review threads =='
gh api graphql -F owner='Gitlawb' -F name='zero' -F number="$pr" -f query='
query($owner: String!, $name: String!, $number: Int!) {
  repository(owner: $owner, name: $name) {
    pullRequest(number: $number) {
      reviewThreads(first: 100) {
        nodes {
          isResolved
          path
          line
          originalLine
          comments(first: 20) {
            nodes { author { login } body createdAt }
          }
        }
      }
    }
  }
}' --jq '
  .data.repository.pullRequest.reviewThreads.nodes[]
  | select(any(.comments.nodes[]?; .author.login == "coderabbitai"))
  | {isResolved, path, line, originalLine,
     comments: [.comments.nodes[] | {author: .author.login, body, createdAt}]}'

printf '%s\n' '== Latest commit =='
git show --no-ext-diff --format='commit %H%nparent %P%nsubject %s%n' --stat HEAD
printf '%s\n' '== Files in latest commit =='
git diff-tree --no-commit-id --name-status -r HEAD

printf '%s\n' '== Relevant source outlines =='
ast-grep outline internal/config/writer.go --items all
ast-grep outline internal/config/json_object_edit.go --items all
ast-grep outline internal/config/pet_writer_test.go --items all

printf '%s\n' '== SetPet and JSON-preserving editor =='
rg -n -C 8 '\bSetPet\s*\(|\bupdatePreferencesPet\s*\(|\bedit.*[Pp]et\b' \
  internal/config/writer.go internal/config/json_object_edit.go

printf '%s\n' '== JSON editor implementation =='
cat -n internal/config/json_object_edit.go

printf '%s\n' '== Pet writer tests =='
cat -n internal/config/pet_writer_test.go

printf '%s\n' '== Latest patch for the fix =='
git diff --no-ext-diff --unified=45 HEAD^ HEAD -- \
  internal/config/writer.go internal/config/json_object_edit.go internal/config/pet_writer_test.go

Length of output: 85069


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '== Run flow and cache-root implementation =='
sed -n '1,175p' internal/tui/run.go

printf '%s\n' '== Pet image output implementation =='
ast-grep outline internal/tui/pet_output.go --items all
cat -n internal/tui/pet_output.go

printf '%s\n' '== Cache-root and render-failure tests =='
rg -n -C 12 \
  'terminalPetFrameCache|terminalPetFrameCacheWith|renderer.*[Ff]ail|Render.*[Ee]rror|KeepsTextAlive|cache is unavailable' \
  internal/tui/run_test.go internal/tui/pet_output_test.go internal/terminalpet/image_renderer.go

printf '%s\n' '== Renderer cache behavior =='
sed -n '285,345p' internal/terminalpet/image_renderer.go

printf '%s\n' '== Relevant changes since PR base =='
git diff --no-ext-diff --unified=30 \
  2458e0c67b49a2e959e4ec068f1f2ad382cb2406 HEAD -- \
  internal/tui/run.go internal/tui/pet_output.go internal/tui/run_test.go internal/tui/pet_output_test.go

Length of output: 64434


@anandh8x Re-review complete.

SetPet now preserves unrelated JSON bytes, member order, and layout. The focused tests cover the prior ordering issue, duplicate preferences members, and multiline empty-object formatting.

The remaining unresolved rendering thread is no longer valid. petImageOutput.Write disables pet rendering after a renderer error and continues terminal output. The regression test verifies this behavior.

All named checks succeeded. I approve this pull request.

[approve]

You are interacting with an AI system.

On Windows Terminal the companion draws a filled black rectangle over the
interface, and dragging it mangles text under and around it. It looks like a
platform quirk. It is an untested code path that only Windows reaches.

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

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

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

Measured before and after rather than reasoned about. Before:

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

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

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

Deliberately not fixed here, since both are design calls rather than defects:
the erase rectangle is computed from the app's own cell maths rather than the
terminal's real footprint, and the sixel path has no in-place move, so every
drag pixel is a full erase and repaint where Kitty gets a cheap reposition.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@anandh8x tested this on Windows Terminal 1.24 and hit the black rectangle plus mangled text under the companion. Pushed a fix for the cause, 0c4a1f2c. Drop it if you would rather own it.

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

That is also why Linux and macOS came back clean. ghostty, kitty, WezTerm and iTerm all resolve to ImageProtocolKitty and delete by id, so they never execute this function. Windows Terminal is the only mainstream terminal that reaches the sixel branch, so no amount of testing on the other two platforms could have found it.

Measured rather than reasoned about. Before:

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

No reset anywhere. The fix leads with one and switches to DECSC/DECRC, because only DECSC saves the attributes this now clobbers, and the erase is emitted mid-frame between styled writes the TUI has already started.

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

I renamed my test file mid-write, worth mentioning because the repo has a guard for it now: I first called it sixel_erase_windows_test.go, which would have compiled on Windows only and silently never run in CI on the other two platforms. It is sixel_erase_test.go, and it is platform-independent because it only inspects emitted bytes.

Two more I found and deliberately did NOT fix

Both are design calls rather than defects, so they are yours.

The erase rectangle is the app's belief, not the terminal's. It erases key.columns x key.rows at remembered coordinates. Sixel pixel dimensions rarely divide evenly into cells, so the real footprint can be a row or column larger: leftovers when it under-erases, eaten UI when it overshoots. Kitty avoids this because the terminal owns the placement. A fix probably means reserving cells the TUI does not draw into, rather than overlaying and erasing.

Sixel has no in-place move. kittyImageContentEqual gives Kitty a cheap reposition; sixel falls through to full clear plus full repaint on every frame and every drag pixel. That is the storm behind the text corruption, and it is why the symptom is worst while dragging.

One unrelated thing while I was in here. DetectImageSupport treats WT_SESSION being set as proof of sixel support, with no version check, while iTerm two branches up gets an explicit dottedVersionAtLeast(..., 3, 6, 0) and a clear refusal message. WT_SESSION has been set by every Windows Terminal since 2019 and sixel only arrived in 1.22, so on an older build the companion emits sixel that is swallowed, shows nothing, and still runs the erase over the UI. Not my symptom, I am on 1.24, but someone will hit it. Windows Terminal exports no version variable, so the honest gate is probably a DA1 query rather than an env sniff, which is why I left it alone rather than guessing.

Also worth a word from you before this lands: go.mod gains golang.org/x/image as a new direct dependency and promotes ultraviolet from indirect. CONTRIBUTING asks for prior justification on dependency changes.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/terminalpet/image_renderer.go`:
- Around line 305-313: In the function containing the DECSC/SGR write and
row-clearing loop, defer the DECRC restore immediately after the initial write
succeeds so every subsequent error path restores terminal state. Preserve any
primary row-write error, but return the deferred restore error when no earlier
error occurred.

In `@internal/terminalpet/sixel_erase_test.go`:
- Around line 57-61: Update the row-validation loop in the sixel erase test to
compute the expected cursor sequence using key.y + row + 1 and key.x + 1 for
each row, then assert that sequence is present in got. Remove the row == 0-only
condition while preserving the existing failure diagnostics and row coverage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: dff45c7d-d3e5-4d2b-b254-47ef6aeaf30c

📥 Commits

Reviewing files that changed from the base of the PR and between c26a97d and 0c4a1f2.

📒 Files selected for processing (2)
  • internal/terminalpet/image_renderer.go
  • internal/terminalpet/sixel_erase_test.go

Comment thread internal/terminalpet/image_renderer.go Outdated
Comment thread internal/terminalpet/sixel_erase_test.go
…painted

The companion blanks a row and a column of live interface on Windows Terminal.
The cause is one predicate.

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

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

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

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

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

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

Tests use the real 20x10 measurement: the sixel footprint is 4 rows not 5, Kitty
keeps its requested placement, the fallback without metrics stays the reserved
area rather than collapsing to zero and erasing nothing, and every image
protocol asks for the measurement.
@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@anandh8x follow-up on the Windows report. I pushed two fixes that are correct and did not solve it, and instrumenting a build finally showed why. The remaining cause is a layer below both, and it is a design call rather than a defect, so I am leaving it with you.

What the two commits did fix

0c4a1f2c the erase painted with the caller's SGR, so it stamped a filled block in the ambient background.

cbb4a64f the cell-size request was gated behind petPixelProtocolSupported, which is Kitty-only, so a sixel session never sent CSI 16 t, never got a reply, and fell back to the blind 75 pixel height and the petImageColumns/petImageRows constants for the erase.

Both confirmed live. Trace from a real Windows Terminal session:

DRAW cellPx=0x0   height=75 cols=9 rows=5 at x=109 y=24 term=120x30
...
DRAW cellPx=10x20 height=75 cols=7 rows=4 at x=109 y=24 term=120x30
RENDER-OUT <ESC>[s<ESC>[25;110H<ESC>P9;1;0q"1;1;69;75#0;2;0;0;0...

Metrics arrive, footprint corrects from 9x5 to 7x4, and the raster header proves it exact: 69px wide over a 10px cell is 6.9 so 7 columns, 75px tall over a 20px cell is 3.75 so 4 rows. Windows Terminal answers CSI 16 t with ESC [6;20;10t, so terminal support was never the problem. Nothing asked.

What is actually breaking the UI

The geometry is now provably right and the damage is unchanged. So it was never the geometry.

The sixel is written out of band, straight into the cell grid, behind Bubble Tea's differential renderer. Bubble Tea keeps a model of what every cell holds and rewrites only the cells it believes changed. The image destroys text in cells it still believes contain that text, so it never repaints them and the characters do not come back.

That matches the symptom precisely. The reports are missing characters and broken borders, not mis-coloured ones: deepseek-v4-flash rendering as deep eek-v4-f ash, the composer border arriving as dashes. Those are cells nobody repainted, not cells wrongly erased.

Three things corroborate it:

  • Kitty is immune because the image is a terminal-owned overlay and the cell grid is never touched, so Bubble Tea's model stays true. That is the real reason Linux and macOS came back clean. It is not a property of those platforms, it is a property of the protocol, and Windows Terminal is the only mainstream terminal that lands on the sixel branch.
  • The only Invalidate() in this path (internal/tui/pet_output.go:63) resets the image renderer's own last-key cache. Nothing marks the destroyed cells dirty.
  • The damage tracks the companion's position and survives it moving away, which is stale cells rather than a bad erase.

Your call

Two routes and I do not think it is mine to pick:

  1. Force a repaint of the affected region after every sixel draw and erase. Correct, but per-cell invalidation may not be exposed, and a full-screen repaint on every drag pixel will flicker.
  2. Reserve the companion's cells so the TUI never renders content underneath it. No overlap, no damage, at the cost of constraining where it can sit. petReservedColumns already carries this idea for the composer wrap.

Two smaller things from the same trace, yours to weigh:

The first ~14 draws run at cellPx=0x0 before the reply lands, so the opening paint still uses the 9x5 fallback. Harmless once a repaint follows, worth knowing.

Sixel has no in-place move. kittyImageContentEqual gives Kitty a cheap reposition; sixel falls through to a full clear and full repaint per frame and per drag pixel, which is why the corruption is worst while dragging.

I deliberately did not widen petPixelProtocolSupported itself in cbb4a64f, since that would switch on pixel-precise dragging for sixel as a side effect. That is a feature decision, not part of this defect.

@anandh8x

Copy link
Copy Markdown
Collaborator Author

@Vasanthdev2004 I pushed d195972d with the Sixel-specific fix. Sixel companions now stay in a reserved dock so they cannot overwrite Bubble Tea cells; Kitty dragging is unchanged. Could you retest on Windows Terminal 1.24 and confirm that text and borders remain intact while output is streaming and pet animations run?

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/terminalpet/sixel_erase_test.go`:
- Around line 60-63: Strengthen the erase validation in the test loop around the
position and erase values: verify that each cursor sequence is followed by
exactly key.columns spaces, rather than using strings.Contains, so longer erase
runs are rejected while the expected clearRenderedImage output remains accepted.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4bd80219-98ee-47ad-9db7-5bc04a35f991

📥 Commits

Reviewing files that changed from the base of the PR and between cbb4a64 and d195972.

📒 Files selected for processing (4)
  • internal/terminalpet/image_renderer.go
  • internal/terminalpet/sixel_erase_test.go
  • internal/tui/pets.go
  • internal/tui/pets_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • internal/terminalpet/image_renderer.go
  • internal/tui/pets.go
  • internal/tui/pets_test.go

Comment thread internal/terminalpet/sixel_erase_test.go

@Vasanthdev2004 Vasanthdev2004 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving d195972d. Tested on Windows Terminal 1.24 throughout, which is the platform this had trouble on.

The dock is the right call. A sixel lives in the cell grid, so wherever it is dragged it destroys text Bubble Tea believes is still present and will not repaint. Kitty images are terminal-owned overlays, so the grid is never touched and free dragging costs nothing. Pinning sixel to a reserved dock removes the overlap rather than trying to repair it afterwards, and gating on petSupportsAlphaOverlay keeps the full behaviour where it is actually safe. The alternative, repainting the damaged region after every move, would have meant a repaint per drag pixel and visible flicker for a worse result.

What I checked rather than assumed, since this fetches and decodes third-party images:

  • Host allowlisting is two-tier and the narrower one is the right way round: catalog redirects may land on petdex.dev, while image and metadata downloads are pinned to the asset host.
  • Redirects are handled properly on both sides. CheckRedirect validates every hop against the appropriate allowlist with a hop cap, and the final response.Request.URL is re-validated afterwards. That second check is what stops a redirect chain ending somewhere the per-hop check let through, and it is a mistake I have seen made in this repo before.
  • Downloads are bounded: 2 MB manifest, 16 MB sprite, enforced with io.LimitReader(body, limit+1) rather than trusting Content-Length, plus a 20 second client timeout.
  • CI is 8/8 including CodeQL and Analyze (actions).

Two things for @kevincodex1 rather than blockers.

go.mod gains golang.org/x/image as a new direct dependency and promotes ultraviolet from indirect. CONTRIBUTING asks for prior justification on dependency changes. The choice looks right to me, it is the Go team's own webp decoder and there is no reasonable alternative for decoding sprite sheets, but the policy call is yours.

Windows users now get a stationary companion where Kitty terminals get a draggable one. That follows from the protocol rather than from anything avoidable, and I would rather ship it docked than shipping it breaking the interface, but it is a platform-parity decision worth making knowingly on a PR titled "interactive terminal companions".

Scope of this approval. I reviewed the rendering and network paths closely and verified the Windows behaviour by hand. I did not re-read all 31 files line by line. The two fixes I pushed earlier, 0c4a1f2c for the erase painting in the caller's background and cbb4a64f for the cell-size request being gated to Kitty only, are both still present and unaffected by the dock change.

Good work chasing this down across three platforms with only one of them able to reproduce it.

@Vasanthdev2004

Copy link
Copy Markdown
Collaborator

@coderabbitai approve

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
✅ Action performed

Comments resolved and changes approved.

@kevincodex1 kevincodex1 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is so cool!

@kevincodex1
kevincodex1 merged commit 0c1de25 into main Aug 11, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants