···3232 compressedIndicator = "⋮"
3333)
34343535+type Formatter interface {
3636+ Format(edits []Edit) string
3737+}
3838+3539// SideBySideFormatter renders diff edits in a split-pane layout with syntax highlighting.
3640type SideBySideFormatter struct {
3741 // TerminalWidth is the total available width for rendering
+106-5
internal/docs/README.md
···11---
22title: Testing Workflow
33updated: 2025-11-08
44-version: 2
44+version: 3
55---
6677"Ride the lightning."
8899This document provides a comprehensive testing workflow for the `storm` changelog manager.
1010-All tests should be run within this repository to validate functionality against real Git history.
1010+All tests should be run within this repository to validate functionality against real Git
1111+history.
11121213## Setup
1314···1617task build
1718```
18192020+## Non-TTY Environment Handling
2121+2222+Storm automatically detects whether it's running in an interactive terminal (TTY) or a
2323+non-interactive environment (CI pipelines, scripts, pipes). Commands gracefully degrade
2424+or provide helpful error messages.
2525+2626+### TTY Detection
2727+2828+The CLI checks for:
2929+3030+- Terminal availability on stdin/stdout
3131+- Common CI environment variables (GITHUB_ACTIONS, GITLAB_CI, CIRCLECI, etc.)
3232+3333+### Command Behavior
3434+3535+#### `generate --interactive`
3636+3737+**Interactive (TTY):** Launches TUI for commit selection
3838+**Non-Interactive:** Returns error with suggestion to use non-interactive mode
3939+4040+```bash
4141+# CI/Non-TTY
4242+storm generate HEAD~5 HEAD --interactive
4343+# Error: flag '--interactive' requires an interactive terminal (detected GitHub Actions environment)
4444+```
4545+4646+**Workaround:**
4747+4848+```bash
4949+# Use without --interactive flag for automatic processing
5050+storm generate HEAD~5 HEAD
5151+```
5252+5353+#### `unreleased review`
5454+5555+**Interactive (TTY):** Launches TUI for reviewing entries
5656+**Non-Interactive:** Returns error with alternatives
5757+5858+```bash
5959+# CI/Non-TTY
6060+storm unreleased review
6161+# Error: command 'storm unreleased review' requires an interactive terminal (detected CI environment)
6262+#
6363+# Alternatives:
6464+# - Use 'storm unreleased list' to view entries in plain text
6565+# - Use 'storm unreleased list --json' for JSON output
6666+```
6767+6868+#### `diff`
6969+7070+**Interactive (TTY):** Launches TUI for navigating diffs
7171+**Non-Interactive:** Outputs plain text diff to stdout
7272+7373+```bash
7474+# CI/Non-TTY - automatically outputs plain text
7575+storm diff HEAD~1 HEAD
7676+# === File 1/3 ===
7777+# --- HEAD~1:file.go
7878+# +++ HEAD:file.go
7979+# [plain text diff output]
8080+```
8181+8282+### Testing Non-TTY Behavior
8383+8484+#### Simulate CI environment
8585+8686+```bash
8787+CI=true storm unreleased review
8888+# Should error with CI-friendly message
8989+```
9090+9191+#### Pipe output
9292+9393+```bash
9494+storm diff HEAD~1 HEAD | less
9595+# Should output plain text diff (not TUI)
9696+```
9797+9898+#### Redirect to file
9999+100100+```bash
101101+storm diff HEAD~1 HEAD > changes.diff
102102+# Should write plain text to file
103103+```
104104+105105+**Expected Behaviors:**
106106+107107+- Clear error messages indicating TTY requirement
108108+- Suggestions for alternative commands
109109+- CI system name detection (e.g., "detected GitHub Actions environment")
110110+- Automatic fallback to plain text for `diff` command
111111+- No ANSI escape codes in piped/redirected output
112112+19113## Core Workflow
2011421115### Manual Entry Creation (`unreleased add`)
···195289- Allows selection/deselection
196290- Creates only selected entries
197291- Handles cancellation (Ctrl+C)
292292+- Errors gracefully in non-TTY with helpful message
198293199294#### Since tag
200295···323418324419- Empty changes directory (should show message, not crash)
325420- Corrupted entry file (should handle gracefully)
326326-- Non-TTY environment (should detect and warn)
421421+- Non-TTY environment (detects and errors with alternatives)
422422+- CI environment (detects CI system name in error message)
327423- Cancel review (Esc/q) - no changes applied
328424- Delete file that no longer exists (should error gracefully)
329425- Edit with empty fields (fields preserve original if empty)
···463559464560**Expected:**
465561466466-- Shows unified diff with syntax highlighting
467467-- Iceberg theme colors
562562+- TTY: Launches interactive TUI with navigation
563563+- Non-TTY: Outputs plain text diff to stdout
564564+- Shows diff with syntax highlighting (TTY only)
565565+- Iceberg theme colors (TTY only)
468566- Context lines displayed
469567- File headers shown
470568···484582- No changes between refs
485583- Binary files (should indicate)
486584- Large diffs (should handle gracefully)
585585+- Non-TTY environment (automatic plain text output)
586586+- Piped output (plain text format)
587587+- Redirected to file (plain text format)
+110
internal/tty/tty.go
···11+// package tty provides utilities for detecting terminal (TTY) availability and
22+// generating appropriate fallback behavior for non-interactive environments.
33+package tty
44+55+import (
66+ "errors"
77+ "fmt"
88+ "os"
99+1010+ "golang.org/x/term"
1111+)
1212+1313+// IsTTY checks if the given file descriptor is a terminal.
1414+func IsTTY(fd uintptr) bool {
1515+ return term.IsTerminal(int(fd))
1616+}
1717+1818+// IsInteractive checks if both stdin and stdout are connected to a terminal.
1919+// This is the primary check for determining if TUI applications can run.
2020+func IsInteractive() bool {
2121+ return IsTTY(os.Stdin.Fd()) && IsTTY(os.Stdout.Fd())
2222+}
2323+2424+// IsCI detects if the current environment is a CI system by checking for common
2525+// CI environment variables.
2626+func IsCI() bool {
2727+ ciEnvVars := []string{
2828+ "CI", // Generic CI indicator
2929+ "CONTINUOUS_INTEGRATION",
3030+ "GITHUB_ACTIONS",
3131+ "GITLAB_CI",
3232+ "CIRCLECI",
3333+ "TRAVIS",
3434+ "JENKINS_URL",
3535+ "BUILDKITE",
3636+ "DRONE",
3737+ "TEAMCITY_VERSION",
3838+ }
3939+4040+ for _, envVar := range ciEnvVars {
4141+ if os.Getenv(envVar) != "" {
4242+ return true
4343+ }
4444+ }
4545+4646+ return false
4747+}
4848+4949+// GetCIName attempts to identify the specific CI system being used.
5050+func GetCIName() string {
5151+ ciMap := map[string]string{
5252+ "GITHUB_ACTIONS": "GitHub Actions",
5353+ "GITLAB_CI": "GitLab CI",
5454+ "CIRCLECI": "CircleCI",
5555+ "TRAVIS": "Travis CI",
5656+ "JENKINS_URL": "Jenkins",
5757+ "BUILDKITE": "Buildkite",
5858+ "DRONE": "Drone CI",
5959+ "TEAMCITY_VERSION": "TeamCity",
6060+ }
6161+6262+ for envVar, name := range ciMap {
6363+ if os.Getenv(envVar) != "" {
6464+ return name
6565+ }
6666+ }
6767+6868+ if IsCI() {
6969+ return "CI"
7070+ }
7171+7272+ return ""
7373+}
7474+7575+// ErrorInteractiveRequired returns a formatted error message indicating that the
7676+// command requires an interactive terminal, with suggestions for alternatives.
7777+func ErrorInteractiveRequired(commandName string, alternatives []string) error {
7878+ msg := fmt.Sprintf("command '%s' requires an interactive terminal", commandName)
7979+8080+ if IsCI() {
8181+ ciName := GetCIName()
8282+ msg += fmt.Sprintf(" (detected %s environment)", ciName)
8383+ } else {
8484+ msg += " (stdin is not a TTY)"
8585+ }
8686+8787+ if len(alternatives) > 0 {
8888+ msg += "\n\nAlternatives:"
8989+ for _, alt := range alternatives {
9090+ msg += fmt.Sprintf("\n - %s", alt)
9191+ }
9292+ }
9393+9494+ return errors.New(msg)
9595+}
9696+9797+// ErrorInteractiveFlag returns a formatted error message indicating that an
9898+// interactive flag cannot be used in a non-TTY environment.
9999+func ErrorInteractiveFlag(flagName string) error {
100100+ msg := fmt.Sprintf("flag '%s' requires an interactive terminal", flagName)
101101+102102+ if IsCI() {
103103+ ciName := GetCIName()
104104+ msg += fmt.Sprintf(" (detected %s environment)", ciName)
105105+ } else {
106106+ msg += " (stdin is not a TTY)"
107107+ }
108108+109109+ return errors.New(msg)
110110+}