From 2a8bd4313ba944bf0bf4cb166384aef177ea8930 Mon Sep 17 00:00:00 2001 From: mAi Date: Fri, 8 May 2026 17:09:59 +0200 Subject: [PATCH] mAi: #5 - tmux-window preview for generate Adds an optional `imagen generate` post-step that opens a sibling tmux window running tmux-img --hold . - 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. --- cmd/imagen/generate.go | 84 ++++++++++++++-- cmd/imagen/generate_test.go | 50 ++++++++++ docs/architecture.md | 1 + docs/usage.md | 20 ++++ internal/config/config.go | 17 +++- internal/config/config_test.go | 28 ++++++ internal/preview/tmux.go | 119 +++++++++++++++++++++++ internal/preview/tmux_test.go | 170 +++++++++++++++++++++++++++++++++ 8 files changed, 479 insertions(+), 10 deletions(-) create mode 100644 cmd/imagen/generate_test.go create mode 100644 internal/preview/tmux.go create mode 100644 internal/preview/tmux_test.go diff --git a/cmd/imagen/generate.go b/cmd/imagen/generate.go index fe6a3d8..19b596c 100644 --- a/cmd/imagen/generate.go +++ b/cmd/imagen/generate.go @@ -11,21 +11,24 @@ import ( "mgit.msbls.de/m/ImaGen/internal/backend" "mgit.msbls.de/m/ImaGen/internal/config" "mgit.msbls.de/m/ImaGen/internal/output" + "mgit.msbls.de/m/ImaGen/internal/preview" "mgit.msbls.de/m/ImaGen/internal/prompt" ) func runGenerate(ctx context.Context, args []string) error { fs := flag.NewFlagSet("generate", flag.ContinueOnError) var ( - backendName string - size string - outPath string - seed int64 - steps int - style string - negative string - configPath string - noSidecar bool + backendName string + size string + outPath string + seed int64 + steps int + style string + negative string + configPath string + noSidecar bool + previewOn bool + previewOff bool ) fs.StringVar(&backendName, "backend", "", "backend instance name (default: config.default_backend)") fs.StringVar(&size, "size", "1024x1024", "WxH, e.g. 1024x1024") @@ -36,6 +39,8 @@ func runGenerate(ctx context.Context, args []string) error { fs.StringVar(&negative, "negative", "", "negative prompt (ignored by backends that don't support it)") fs.StringVar(&configPath, "config", "", "config file path (default: ~/.config/imagen.yaml)") fs.BoolVar(&noSidecar, "no-sidecar", false, "skip the JSON sidecar even if config enables it") + fs.BoolVar(&previewOn, "preview", false, "force tmux preview window on (errors outside $TMUX)") + fs.BoolVar(&previewOff, "no-preview", false, "skip the tmux preview window") fs.Usage = func() { fmt.Fprintln(fs.Output(), `Usage: imagen generate "" [flags]`) fs.PrintDefaults() @@ -118,9 +123,70 @@ func runGenerate(ctx context.Context, args []string) error { if paths.SidecarPath != "" { fmt.Fprintln(os.Stderr, "sidecar:", paths.SidecarPath) } + + if err := maybePreview(cfg, previewOn, previewOff, paths.ImagePath, rawPrompt); err != nil { + // preview failures are warnings — the image already wrote. + fmt.Fprintln(os.Stderr, "imagen: preview:", err) + } return nil } +// resolvePreviewMode applies the precedence chain config -> env -> flag. +// Flags win, env beats config, config beats the implicit auto default. +func resolvePreviewMode(cfg *config.Config, flagOn, flagOff bool, env string) (preview.Mode, error) { + mode := preview.ModeAuto + if cfg != nil && cfg.Output.Preview != "" { + m, err := preview.ParseMode(cfg.Output.Preview) + if err != nil { + return "", fmt.Errorf("config output.preview: %w", err) + } + mode = m + } + if env != "" { + m, err := preview.ParseMode(env) + if err != nil { + return "", fmt.Errorf("$IMAGEN_PREVIEW: %w", err) + } + mode = m + } + if flagOn && flagOff { + return "", userErr("--preview and --no-preview are mutually exclusive") + } + if flagOn { + mode = preview.ModeOn + } + if flagOff { + mode = preview.ModeOff + } + return mode, nil +} + +// maybePreview resolves the effective preview mode and, if it says yes, +// spawns a tmux window via tmux-img. Always non-fatal. +func maybePreview(cfg *config.Config, flagOn, flagOff bool, imagePath, rawPrompt string) error { + mode, err := resolvePreviewMode(cfg, flagOn, flagOff, os.Getenv("IMAGEN_PREVIEW")) + if err != nil { + return err + } + decision, err := preview.Resolve(mode, os.Getenv("TMUX") != "", stdoutIsTTY()) + if err != nil { + return err + } + if !decision.ShouldPreview { + return nil + } + spawner := &preview.Spawner{} + return spawner.Spawn(imagePath, output.Slug(rawPrompt)) +} + +func stdoutIsTTY() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + // splitLeadingPositional separates the positional args at the start of args // from the rest (which begins with the first flag). A literal "--" terminator // pushes everything after it into the positional list and out of flag parsing. diff --git a/cmd/imagen/generate_test.go b/cmd/imagen/generate_test.go new file mode 100644 index 0000000..c979df0 --- /dev/null +++ b/cmd/imagen/generate_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "testing" + + "mgit.msbls.de/m/ImaGen/internal/config" + "mgit.msbls.de/m/ImaGen/internal/preview" +) + +func TestResolvePreviewMode(t *testing.T) { + type tc struct { + name string + cfg *config.Config + flagOn bool + flagOff bool + env string + want preview.Mode + wantError bool + } + cases := []tc{ + {name: "all-empty-defaults-to-auto", want: preview.ModeAuto}, + {name: "config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, want: preview.ModeOn}, + {name: "config-off", cfg: &config.Config{Output: config.OutputConfig{Preview: "off"}}, want: preview.ModeOff}, + {name: "config-auto-explicit", cfg: &config.Config{Output: config.OutputConfig{Preview: "auto"}}, want: preview.ModeAuto}, + {name: "env-overrides-config", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, env: "off", want: preview.ModeOff}, + {name: "flag-on-overrides-env-off", env: "off", flagOn: true, want: preview.ModeOn}, + {name: "flag-off-overrides-env-on", env: "on", flagOff: true, want: preview.ModeOff}, + {name: "flag-off-overrides-config-on", cfg: &config.Config{Output: config.OutputConfig{Preview: "on"}}, flagOff: true, want: preview.ModeOff}, + {name: "both-flags-error", flagOn: true, flagOff: true, wantError: true}, + {name: "bad-env-errors", env: "yes", wantError: true}, + {name: "bad-config-errors", cfg: &config.Config{Output: config.OutputConfig{Preview: "yes"}}, wantError: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, err := resolvePreviewMode(c.cfg, c.flagOn, c.flagOff, c.env) + if c.wantError { + if err == nil { + t.Fatalf("expected error, got mode %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != c.want { + t.Errorf("mode = %q, want %q", got, c.want) + } + }) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 4b4960f..72c519f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,6 +15,7 @@ upstream API. Each adapter only ever sees its own slice of `imagen.yaml`. │ internal/prompt │ style preset → prompt suffix │ internal/output │ filename templating, sidecar │ internal/config │ YAML loader, validation + │ internal/preview │ tmux-img window spawner └──────────┬────────────┘ │ ┌──────────▼────────────┐ diff --git a/docs/usage.md b/docs/usage.md index 21df79e..3f347d0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -24,8 +24,28 @@ imagen version print version | `--negative` | empty | Negative prompt (ignored by some adapters) | | `--output` | empty (= use naming template) | Explicit path | | `--no-sidecar` | `false` | Skip the JSON sidecar even if config enables it | +| `--preview` | (auto) | Force open a tmux preview window via `tmux-img` | +| `--no-preview` | (auto) | Suppress the preview window (use for batch / CI callers) | | `--config` | `~/.config/imagen.yaml` | Override config path | +### Preview window + +After a successful generate, imagen optionally opens a sibling tmux window +named `img:` running `tmux-img --hold `. The new window is +spawned in the background (`tmux new-window -d`) so the generating pane +keeps focus and its terminal output. + +Resolution order is **config → `$IMAGEN_PREVIEW` → flag** (later wins): + +- `output.preview` in `imagen.yaml`: `auto` (default) | `on` | `off` +- `IMAGEN_PREVIEW=auto|on|off` overrides config +- `--preview` / `--no-preview` override env + +`auto` previews iff stdout is a TTY *and* `$TMUX` is set. `on` previews +unconditionally and errors outside a tmux session. `off` never previews. + +Preview failures are non-fatal — the image already wrote. + ## Examples ```sh diff --git a/internal/config/config.go b/internal/config/config.go index 64ddafa..15c08f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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: diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f500ced..222c788 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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{ diff --git a/internal/preview/tmux.go b/internal/preview/tmux.go new file mode 100644 index 0000000..32d5253 --- /dev/null +++ b/internal/preview/tmux.go @@ -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: running tmux-img --hold +// . -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, "'", `'\''`) + "'" +} diff --git a/internal/preview/tmux_test.go b/internal/preview/tmux_test.go new file mode 100644 index 0000000..4752195 --- /dev/null +++ b/internal/preview/tmux_test.go @@ -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: '' + 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) + } +}