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:
@@ -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:
|
||||
|
||||
@@ -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
119
internal/preview/tmux.go
Normal 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, "'", `'\''`) + "'"
|
||||
}
|
||||
170
internal/preview/tmux_test.go
Normal file
170
internal/preview/tmux_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user