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.
120 lines
3.5 KiB
Go
120 lines
3.5 KiB
Go
// 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, "'", `'\''`) + "'"
|
|
}
|