mAi: #5 - tmux-window preview for generate

Adds an optional `imagen generate` post-step that opens a sibling
tmux window running tmux-img --hold <path>.

- internal/preview: Mode (auto|on|off), Resolve, and a Spawner that
  shells out to tmux new-window. Typed errors for missing tmux,
  missing tmux-img, and "preview forced on outside $TMUX".
- cmd/imagen/generate: --preview / --no-preview flags plus
  $IMAGEN_PREVIEW. Resolution chain: config -> env -> flag.
  auto requires both stdout-is-tty and $TMUX. Failures are
  warnings - the image is already on disk.
- internal/config: output.preview field, validated to auto|on|off,
  threaded into the sample.
- Tests for ParseMode, Resolve, Spawn argv (incl. shell quoting of
  paths with apostrophes), missing-binary errors, and the CLI
  resolution table.
- Docs (usage + architecture) updated.

/imagine SKILL.md edit lives in dotfiles - deferred to coordinate
with #4.
This commit is contained in:
mAi
2026-05-08 17:09:59 +02:00
parent 4183d4c55a
commit 2a8bd4313b
8 changed files with 479 additions and 10 deletions

View File

@@ -19,11 +19,16 @@ type Config struct {
Backends map[string]BackendSpec `yaml:"backends"`
}
// OutputConfig controls where generated images and metadata sidecars land.
// OutputConfig controls where generated images and metadata sidecars land,
// and whether `imagen generate` opens a tmux preview window.
type OutputConfig struct {
Directory string `yaml:"directory"`
Naming string `yaml:"naming"`
WriteMetadataJSON bool `yaml:"write_metadata_json"`
// Preview is the tri-state preview mode: "auto" (default), "on", "off".
// Empty / unset is treated as "auto". $IMAGEN_PREVIEW and the
// --preview/--no-preview flags override this in turn.
Preview string `yaml:"preview"`
}
// BackendSpec is one entry under `backends:`. Type identifies the adapter;
@@ -78,6 +83,11 @@ func (c *Config) Validate() error {
return fmt.Errorf("default_backend %q is not defined under backends:", c.DefaultBackend)
}
}
switch c.Output.Preview {
case "", "auto", "on", "off":
default:
return fmt.Errorf("output.preview = %q (must be auto|on|off)", c.Output.Preview)
}
for name, spec := range c.Backends {
if name == "" {
return errors.New("empty backend name")
@@ -101,6 +111,11 @@ output:
directory: ~/Pictures/imagen
naming: "{date}-{slug}-{seed}.png"
write_metadata_json: true
# Open a tmux window with tmux-img after a successful generation.
# auto (default): preview iff stdout is a TTY and $TMUX is set.
# on: always preview (errors outside a tmux session).
# off: never preview (use this for batch / CI callers).
preview: auto
backends:
flux-schnell-local:

View File

@@ -60,6 +60,34 @@ func TestValidateRejectsMissingType(t *testing.T) {
}
}
func TestValidatePreviewMode(t *testing.T) {
for _, mode := range []string{"", "auto", "on", "off"} {
c := &Config{Output: OutputConfig{Preview: mode}}
if err := c.Validate(); err != nil {
t.Errorf("preview=%q: unexpected error %v", mode, err)
}
}
bad := &Config{Output: OutputConfig{Preview: "yes"}}
if err := bad.Validate(); err == nil {
t.Errorf("expected error for invalid preview value")
}
}
func TestSampleParsesPreviewAuto(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "imagen.yaml")
if err := os.WriteFile(path, []byte(Sample), 0o644); err != nil {
t.Fatalf("write sample: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Output.Preview != "auto" {
t.Errorf("Output.Preview = %q, want auto", cfg.Output.Preview)
}
}
func TestExpandPath(t *testing.T) {
home, _ := os.UserHomeDir()
cases := map[string]string{

119
internal/preview/tmux.go Normal file
View File

@@ -0,0 +1,119 @@
// Package preview opens a tmux window showing a generated image via tmux-img.
// Mode resolution and the actual spawn are kept separate so the CLI can
// decide-then-act and tests can drive each half independently.
package preview
import (
"errors"
"fmt"
"os/exec"
"strings"
)
// Mode is the tri-state preview setting: auto (default), on (force), off.
type Mode string
const (
ModeAuto Mode = "auto"
ModeOn Mode = "on"
ModeOff Mode = "off"
)
// ParseMode normalises a string into a Mode. Empty parses to ModeAuto so
// callers can pass through unset config / env values.
func ParseMode(s string) (Mode, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "auto":
return ModeAuto, nil
case "on":
return ModeOn, nil
case "off":
return ModeOff, nil
}
return "", fmt.Errorf("invalid preview mode %q (auto|on|off)", s)
}
// Decision is the answer to "should we preview, and why".
type Decision struct {
ShouldPreview bool
Reason string
}
// Resolve maps (mode, runtime context) to a Decision.
//
// - off -> never preview
// - on -> preview, but error if not in tmux (forced on outside tmux)
// - auto -> preview iff inTmux && stdoutTTY
func Resolve(mode Mode, inTmux, stdoutTTY bool) (Decision, error) {
switch mode {
case ModeOff:
return Decision{ShouldPreview: false, Reason: "preview=off"}, nil
case ModeOn:
if !inTmux {
return Decision{}, ErrNoTmuxForced
}
return Decision{ShouldPreview: true, Reason: "preview=on"}, nil
case ModeAuto, "":
if !inTmux {
return Decision{ShouldPreview: false, Reason: "auto: $TMUX unset"}, nil
}
if !stdoutTTY {
return Decision{ShouldPreview: false, Reason: "auto: stdout not a tty"}, nil
}
return Decision{ShouldPreview: true, Reason: "auto"}, nil
}
return Decision{}, fmt.Errorf("invalid preview mode %q", mode)
}
// Errors returned by Spawn and Resolve. Each names the missing piece and,
// where relevant, where to install it.
var (
ErrTmuxMissing = errors.New("tmux: binary not found on $PATH (required for image preview)")
ErrTmuxImgMissing = errors.New("tmux-img: binary not found on $PATH (install at ~/.local/bin/tmux-img)")
ErrNoTmuxForced = errors.New("--preview requires $TMUX (are you in a tmux session?)")
)
// Spawner spawns the tmux preview window. The exec.LookPath / cmd.Run hooks
// exist so tests can inject fakes without touching $PATH.
type Spawner struct {
LookPath func(string) (string, error)
Run func(*exec.Cmd) error
}
// Spawn opens a new tmux window named img:<slug> running tmux-img --hold
// <imagePath>. -d keeps focus in the current pane. Caller is expected to
// have already verified that we are inside a tmux session.
func (s *Spawner) Spawn(imagePath, slug string) error {
look := s.LookPath
if look == nil {
look = exec.LookPath
}
run := s.Run
if run == nil {
run = func(c *exec.Cmd) error { return c.Run() }
}
tmuxBin, err := look("tmux")
if err != nil {
return ErrTmuxMissing
}
tmuxImgBin, err := look("tmux-img")
if err != nil {
return ErrTmuxImgMissing
}
name := "img:" + slug
shellCmd := fmt.Sprintf("%s --hold %s",
shellQuote(tmuxImgBin), shellQuote(imagePath))
cmd := exec.Command(tmuxBin, "new-window", "-d", "-n", name, shellCmd)
if err := run(cmd); err != nil {
return fmt.Errorf("tmux new-window: %w", err)
}
return nil
}
// shellQuote single-quotes s for /bin/sh — tmux passes the trailing arg of
// new-window through a shell.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -0,0 +1,170 @@
package preview
import (
"errors"
"os/exec"
"strings"
"testing"
)
func TestParseMode(t *testing.T) {
cases := map[string]Mode{
"": ModeAuto,
"auto": ModeAuto,
"AUTO": ModeAuto,
"on": ModeOn,
" on ": ModeOn,
"off": ModeOff,
}
for in, want := range cases {
got, err := ParseMode(in)
if err != nil {
t.Errorf("ParseMode(%q) err = %v", in, err)
continue
}
if got != want {
t.Errorf("ParseMode(%q) = %q, want %q", in, got, want)
}
}
if _, err := ParseMode("nope"); err == nil {
t.Errorf("ParseMode(nope) should have errored")
}
}
func TestResolve(t *testing.T) {
type tc struct {
mode Mode
inTmux bool
stdoutTTY bool
want bool
wantErr error
}
cases := map[string]tc{
"off-anywhere": {ModeOff, false, false, false, nil},
"off-in-tmux-tty": {ModeOff, true, true, false, nil},
"on-in-tmux": {ModeOn, true, false, true, nil},
"on-outside-tmux-errs": {ModeOn, false, true, false, ErrNoTmuxForced},
"auto-no-tmux": {ModeAuto, false, true, false, nil},
"auto-tmux-no-tty": {ModeAuto, true, false, false, nil},
"auto-tmux-and-tty": {ModeAuto, true, true, true, nil},
}
for name, c := range cases {
t.Run(name, func(t *testing.T) {
d, err := Resolve(c.mode, c.inTmux, c.stdoutTTY)
if c.wantErr != nil {
if !errors.Is(err, c.wantErr) {
t.Fatalf("err = %v, want %v", err, c.wantErr)
}
return
}
if err != nil {
t.Fatalf("err = %v", err)
}
if d.ShouldPreview != c.want {
t.Errorf("ShouldPreview = %v, want %v (reason: %s)", d.ShouldPreview, c.want, d.Reason)
}
})
}
}
func TestSpawn_BuildsCorrectCommand(t *testing.T) {
var captured *exec.Cmd
s := &Spawner{
LookPath: func(name string) (string, error) {
switch name {
case "tmux":
return "/usr/bin/tmux", nil
case "tmux-img":
return "/home/m/.local/bin/tmux-img", nil
}
return "", exec.ErrNotFound
},
Run: func(c *exec.Cmd) error {
captured = c
return nil
},
}
if err := s.Spawn("/tmp/imagen/cat.png", "cat-in-a-fishbowl"); err != nil {
t.Fatalf("Spawn: %v", err)
}
if captured == nil {
t.Fatal("Run was not called")
}
if captured.Path != "/usr/bin/tmux" {
t.Errorf("Path = %q, want /usr/bin/tmux", captured.Path)
}
args := captured.Args
if len(args) < 6 {
t.Fatalf("args = %v (need at least 6)", args)
}
// tmux new-window -d -n img:<slug> '<shell-cmd>'
if args[1] != "new-window" {
t.Errorf("args[1] = %q, want new-window", args[1])
}
if args[2] != "-d" {
t.Errorf("args[2] = %q, want -d", args[2])
}
if args[3] != "-n" {
t.Errorf("args[3] = %q, want -n", args[3])
}
if args[4] != "img:cat-in-a-fishbowl" {
t.Errorf("args[4] = %q, want img:cat-in-a-fishbowl", args[4])
}
shellCmd := args[5]
if !strings.Contains(shellCmd, "tmux-img") || !strings.Contains(shellCmd, "--hold") || !strings.Contains(shellCmd, "/tmp/imagen/cat.png") {
t.Errorf("shell cmd %q missing expected pieces", shellCmd)
}
}
func TestSpawn_PathWithSpacesAndQuotes(t *testing.T) {
var captured *exec.Cmd
s := &Spawner{
LookPath: func(name string) (string, error) {
if name == "tmux" {
return "/usr/bin/tmux", nil
}
if name == "tmux-img" {
return "/usr/local/bin/tmux-img", nil
}
return "", exec.ErrNotFound
},
Run: func(c *exec.Cmd) error { captured = c; return nil },
}
weird := "/tmp/imagen/o'malley's cat.png"
if err := s.Spawn(weird, "slug"); err != nil {
t.Fatalf("Spawn: %v", err)
}
shellCmd := captured.Args[5]
// Single-quoted with the embedded apostrophe escaped via the
// '\'' shell idiom — confirm we did not just splice the raw path.
if strings.Contains(shellCmd, "o'malley's") {
t.Errorf("shell cmd %q contains unescaped apostrophes", shellCmd)
}
}
func TestSpawn_MissingTmux(t *testing.T) {
s := &Spawner{
LookPath: func(string) (string, error) { return "", exec.ErrNotFound },
Run: func(*exec.Cmd) error { return nil },
}
err := s.Spawn("/x.png", "s")
if !errors.Is(err, ErrTmuxMissing) {
t.Errorf("err = %v, want ErrTmuxMissing", err)
}
}
func TestSpawn_MissingTmuxImg(t *testing.T) {
s := &Spawner{
LookPath: func(name string) (string, error) {
if name == "tmux" {
return "/usr/bin/tmux", nil
}
return "", exec.ErrNotFound
},
Run: func(*exec.Cmd) error { return nil },
}
err := s.Spawn("/x.png", "s")
if !errors.Is(err, ErrTmuxImgMissing) {
t.Errorf("err = %v, want ErrTmuxImgMissing", err)
}
}