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:
@@ -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 "<prompt>" [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.
|
||||
|
||||
50
cmd/imagen/generate_test.go
Normal file
50
cmd/imagen/generate_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user