From 377d82f4ec0f66a066acaef60754805387367e11 Mon Sep 17 00:00:00 2001 From: Tig Date: Fri, 5 Jun 2026 19:48:22 -0600 Subject: [PATCH 1/2] Add --select to pass agg's frame range through Forwards a --select value to agg's --select so a recording's lead-in can be trimmed at render time. This is useful for sixel apps (e.g. Terminal.Gui's Mandelbrot scenario) that paint a cell-based fallback frame before the terminal's sixel-capability handshake completes: --select 0.1.. drops that pre-handshake frame so the GIF opens on the real sixel content instead of a one-frame fallback flash. The value is plumbed through gif.Config.Select into renderArgs and exposed on both the record and render commands. Covered by a renderArgs unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/tuirec/main.go | 2 ++ pkg/gif/gif.go | 8 ++++++++ pkg/gif/gif_test.go | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+) diff --git a/cmd/tuirec/main.go b/cmd/tuirec/main.go index eea6a45..514640f 100644 --- a/cmd/tuirec/main.go +++ b/cmd/tuirec/main.go @@ -233,6 +233,7 @@ pacing to stderr.`, cmd.Flags().Float64Var(&flags.config.GIF.LineHeight, "line-height", flags.config.GIF.LineHeight, "Line-height multiplier") cmd.Flags().Float64Var(&flags.config.GIF.LetterSpacing, "letter-spacing", flags.config.GIF.LetterSpacing, "Letter-spacing adjustment in pixels (negative closes gaps)") cmd.Flags().Float64Var(&flags.config.GIF.Speed, "speed", flags.config.GIF.Speed, "GIF playback speed multiplier") + cmd.Flags().StringVar(&flags.config.GIF.Select, "select", flags.config.GIF.Select, "agg --select frame range (e.g. \"0.2..\") to trim the recording's lead-in") cmd.Flags().IntVar(&flags.maxDurationSec, "max-duration", flags.maxDurationSec, "Max recording duration in seconds") cmd.Flags().StringVar(&flags.config.Title, "title", "", "Title embedded in the cast file") cmd.Flags().StringVar(&flags.config.GIF.AggPath, "agg-path", flags.config.GIF.AggPath, "Path to agg binary") @@ -308,6 +309,7 @@ or a timeline point in milliseconds (at:).`, cmd.Flags().Float64Var(&flags.config.GIF.LineHeight, "line-height", flags.config.GIF.LineHeight, "Line-height multiplier") cmd.Flags().Float64Var(&flags.config.GIF.LetterSpacing, "letter-spacing", flags.config.GIF.LetterSpacing, "Letter-spacing adjustment in pixels (negative closes gaps)") cmd.Flags().Float64Var(&flags.config.GIF.Speed, "speed", flags.config.GIF.Speed, "Render speed multiplier before frame selection") + cmd.Flags().StringVar(&flags.config.GIF.Select, "select", flags.config.GIF.Select, "agg --select frame range (e.g. \"0.2..\") to trim the recording's lead-in") cmd.Flags().IntVar(&flags.maxDurationSec, "max-duration", flags.maxDurationSec, "Max recording duration in seconds") cmd.Flags().StringVar(&flags.config.Title, "title", "", "Title embedded in the cast file") cmd.Flags().StringVar(&flags.config.GIF.AggPath, "agg-path", flags.config.GIF.AggPath, "Path to agg binary") diff --git a/pkg/gif/gif.go b/pkg/gif/gif.go index 2fa0ff1..1d3ce34 100644 --- a/pkg/gif/gif.go +++ b/pkg/gif/gif.go @@ -34,6 +34,11 @@ type Config struct { FontSize int LineHeight float64 LetterSpacing float64 + // Select is passed to agg's --select to render only part of the timeline. + // Use it to trim a recording's lead-in (e.g. "0.2.." drops the first 0.2s), + // which is handy for sixel apps that paint a cell-based fallback frame + // before the terminal's sixel-capability handshake completes. + Select string } // Validation describes a decoded GIF. @@ -72,6 +77,9 @@ func renderArgs(castPath, outputPath string, config Config) []string { if config.LetterSpacing != 0 { args = append(args, "--letter-spacing", formatFloat(config.LetterSpacing)) } + if config.Select != "" { + args = append(args, "--select", config.Select) + } args = append(args, castPath, outputPath) return args diff --git a/pkg/gif/gif_test.go b/pkg/gif/gif_test.go index e41da89..7820e5c 100644 --- a/pkg/gif/gif_test.go +++ b/pkg/gif/gif_test.go @@ -87,6 +87,24 @@ func TestRenderArgsIncludesLetterSpacingWhenSet(t *testing.T) { } } +func TestRenderArgsIncludesSelectWhenSet(t *testing.T) { + t.Parallel() + + got := renderArgs("in.cast", "out.gif", NormalizeConfig(Config{Select: "0.2.."})) + want := []string{ + "--theme", "monokai", + "--speed", "1", + "--font-size", "14", + "--line-height", "1.3", + "--select", "0.2..", + "in.cast", "out.gif", + } + + if !reflect.DeepEqual(got, want) { + t.Fatalf("renderArgs() = %#v, want %#v", got, want) + } +} + func TestValidateRejectsSingleFrameGIF(t *testing.T) { t.Parallel() From ead58e3790f85d171bac6403e2f1de16b68885b5 Mon Sep 17 00:00:00 2001 From: Tig Date: Sat, 6 Jun 2026 06:25:16 -0600 Subject: [PATCH 2/2] Limit --select to the record command The snapshot command renders its PNG on agg's selected timeline but reconstructs assertion / print-frame-text from the unselected cast by frame index, so --select made the inspected frame disagree with the rendered one (e.g. `snapshot --select 0.2.. --frame 0 --assert-contains`). --select exists to trim a recording's lead-in, which is a GIF concern; snapshot already targets any frame via --frame at:, so drop --select there and keep it on record. Guarded by TestSelectFlagOnlyOnRecord (record has it, snapshot does not) and TestRecordCommandParsesSelect (the value reaches GIF.Select). Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/tuirec/main.go | 1 - cmd/tuirec/main_test.go | 45 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/cmd/tuirec/main.go b/cmd/tuirec/main.go index 514640f..bcae001 100644 --- a/cmd/tuirec/main.go +++ b/cmd/tuirec/main.go @@ -309,7 +309,6 @@ or a timeline point in milliseconds (at:).`, cmd.Flags().Float64Var(&flags.config.GIF.LineHeight, "line-height", flags.config.GIF.LineHeight, "Line-height multiplier") cmd.Flags().Float64Var(&flags.config.GIF.LetterSpacing, "letter-spacing", flags.config.GIF.LetterSpacing, "Letter-spacing adjustment in pixels (negative closes gaps)") cmd.Flags().Float64Var(&flags.config.GIF.Speed, "speed", flags.config.GIF.Speed, "Render speed multiplier before frame selection") - cmd.Flags().StringVar(&flags.config.GIF.Select, "select", flags.config.GIF.Select, "agg --select frame range (e.g. \"0.2..\") to trim the recording's lead-in") cmd.Flags().IntVar(&flags.maxDurationSec, "max-duration", flags.maxDurationSec, "Max recording duration in seconds") cmd.Flags().StringVar(&flags.config.Title, "title", "", "Title embedded in the cast file") cmd.Flags().StringVar(&flags.config.GIF.AggPath, "agg-path", flags.config.GIF.AggPath, "Path to agg binary") diff --git a/cmd/tuirec/main_test.go b/cmd/tuirec/main_test.go index ba5895a..2dc9a81 100644 --- a/cmd/tuirec/main_test.go +++ b/cmd/tuirec/main_test.go @@ -591,6 +591,51 @@ func TestRecordCommandNameFlag(t *testing.T) { } } +func TestSelectFlagOnlyOnRecord(t *testing.T) { + t.Parallel() + + opts := cliOptions{stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}} + + if f := newRecordCommand(opts).Flags().Lookup("select"); f == nil { + t.Fatal("record command should expose --select to trim the GIF lead-in") + } + + // snapshot must NOT expose --select: it would apply agg's selection to the + // rendered PNG while the assertion/print-frame-text path reconstructs text + // from the unselected cast by frame index, so the two would disagree about + // which frame is being inspected. snapshot already targets any frame via + // --frame at:. + if f := newSnapshotCommand(opts).Flags().Lookup("select"); f != nil { + t.Fatal("snapshot command must not expose --select (desyncs PNG from assertion text)") + } +} + +func TestRecordCommandParsesSelect(t *testing.T) { + t.Parallel() + + var got record.Config + code := execute([]string{ + "record", + "--binary", "demo-app", + "--select", "0.2..", + "--keystrokes", "wait:10,Ctrl+Q", + }, cliOptions{ + stdout: &bytes.Buffer{}, + stderr: &bytes.Buffer{}, + look: func(path string) (string, error) { return path, nil }, + run: func(_ context.Context, config record.Config) (record.Result, error) { + got = config + return record.Result{CastPath: config.CastOutput, GIFPath: config.Output}, nil + }, + }) + if code != exitSuccess { + t.Fatalf("execute code = %d, want %d", code, exitSuccess) + } + if got.GIF.Select != "0.2.." { + t.Fatalf("GIF.Select = %q, want %q", got.GIF.Select, "0.2..") + } +} + func TestRecordCommandNameFlagExplicitOutputOverrides(t *testing.T) { t.Parallel()