---
name: Writing User Outputs
slug: writing-user-outputs
category: Quality
description: Writing User Outputs defines CLI output formatting standards for worktrunk, including ANSI color nesting, message patterns, and stdout/stderr rules. Load it before editing code that produces user-visible strings.
github: "https://github.com/max-sixty/worktrunk/tree/main/.claude/skills/writing-user-outputs"
language: Rust
stars: 6453
forks: 232
install: "npx degit https://github.com/max-sixty/worktrunk/tree/main/.claude/skills/writing-user-outputs ~/.claude/skills/writing-user-outputs"
installs_to: ~/.claude/skills/writing-user-outputs
source_path: .claude/skills/writing-user-outputs/SKILL.md
collection_size: 5
category_size: 1354
collection_url: "https://dirskills.com/collections/max-sixty/worktrunk"
added: 2026-08-15T06:52:00.025Z
last_synced: 2026-08-15T06:52:00.025Z
canonical_url: "https://dirskills.com/skills/writing-user-outputs"
---

# Writing User Outputs

Writing User Outputs defines CLI output formatting standards for worktrunk, including ANSI color nesting, message patterns, and stdout/stderr rules. Load it before editing code that produces user-visible strings.

**Install:**

```bash
npx degit https://github.com/max-sixty/worktrunk/tree/main/.claude/skills/writing-user-outputs ~/.claude/skills/writing-user-outputs
```

## README

# Output System Architecture

## Shell Integration

Worktrunk uses split file-based directive passing for shell integration:

1. Shell wrapper creates two temp files via `mktemp` (cd and exec)
2. Shell wrapper sets `WORKTRUNK_DIRECTIVE_CD_FILE` and `WORKTRUNK_DIRECTIVE_EXEC_FILE`
3. wt writes a raw path to the CD file; shell commands to the EXEC file (for `--execute`)
4. Shell wrapper reads the CD file with `cd -- "$(< file)"` (no shell parsing)
5. Shell wrapper sources the EXEC file if non-empty

When neither directive env var is set (direct binary call), commands execute
directly and shell integration hints are shown.

## Output Functions

The output system handles shell integration automatically. Just call output
functions — they do the right thing regardless of whether shell integration is
active.

```rust
// NEVER DO THIS - don't check mode in command code
if is_shell_integration_active() {
    // different behavior
}

// ALWAYS DO THIS - just call output functions
eprintln!("{}", success_message("Created worktree"));
output::change_directory(&path)?;  // Writes to directive file if set, else no-op
```

**Printing output:**

Use `eprintln!` and `println!` from `worktrunk::styling` (re-exported from
`anstream` for automatic color support and TTY detection):

```rust
use worktrunk::styling::{eprintln, println, stderr};

// Status messages to stderr
eprintln!("{}", success_message("Created worktree"));

// Primary output to stdout (tables, shell code, pipeable)
println!("{}", table_output);

// Flush before interactive prompts
stderr().flush()?;
```

Which `println!` is in scope decides whether a closed pipe panics: std's
panics on the `BrokenPipe` write error, anstream's drops it. `wt … | head`
closes the pipe, so command code imports the `worktrunk::styling` one and no
`std::println!` is left in `src/`.

The stderr macros carry the same rule for a different consequence: anstream's
`eprint!` / `eprintln!` strip ANSI when stderr isn't a terminal, std's keep it,
so a file importing one but not the other writes escapes on one line of a
message block and not the next under `wt … 2>log`. `eprint!` is the half that
slips — it has no newline, so it gets reached for mid-block in a file that
imported only `eprintln`. Every bare `eprint!` / `eprintln!` under `src/` must
resolve to anstream's: import it, or qualify the call as
`styling::eprintln!(…)`. `check_stderr_macros_come_from_styling` in
`tests/integration_tests/output_system_guard.rs` holds that statically, since
no snapshot can — the suite forces `CLICOLOR_FORCE=1`, so both printers emit
color and a snapshot agrees whichever macro is in scope. Its
`STD_STDERR_ALLOWED_PATHS` exempts whole files, not calls, so an entry is only
right where std's macro is right throughout.

**Output whose ANSI is already decided** declares that once at the top of the
command with `worktrunk::styling::ColorChoice::Always.write_global()` and then
prints through the same anstream macros — the statusline a shell prompt or
Claude Code renders, and the `--help-page` document whose escapes the docs
pipeline turns into HTML (`--plain` and `--help-md` declare `Never` the same
way). Neither consumer is ever a tty, so without the declaration anstream
would strip their color every time — and the test suite would not catch it,
because it forces color with `CLICOLOR_FORCE=1`;
`test_color_follows_the_consumer` pins the unforced behavior. Declare `Always`
only when the pipe is a courier rather than the destination; anything a person
reads directly stays on plain anstream, which is what strips color on a pipe
and honors `NO_COLOR`.

**`--format=json` answers** go through `crate::output::print_json`, never a
hand-rolled `println!("{}", serde_json::to_string_pretty(&v)?)`. It serializes
pretty with one trailing newline and prints through anstream, so no
`--format=json` surface panics when its consumer stops reading. Before that,
thirty call sites had open-coded those two lines, and whether any one of them
panicked under `| head -3` came down to which `println!` its module happened to
import. `wt switch --format=json` is the one non-caller: it emits its single
result as one compact line (still through anstream's `println!`), because that
is what a shell loop reads.

**Shell integration functions** (`src/output/global.rs`):

| Function | Purpose |
|----------|---------|
| `change_directory(path)` | Shell cd after wt exits (writes to directive file if set) |
| `execute(command)` | Shell command after wt exits |
| `terminate_output()` | Reset ANSI state on stderr |
| `is_shell_integration_active()` | Check if directive file set (rarely needed) |
| `pre_hook_display_path(path)` | Compute display path for pre-hooks |
| `post_hook_display_path(path)` | Compute display path for post-hooks |

**Message formatting functions** (`worktrunk::styling`):

| Function | Symbol | Color |
|----------|--------|-------|
| `success_message()` | ✓ | green |
| `progress_message()` | ◎ | cyan |
| `info_message()` | ○ | symbol dim, text plain |
| `warning_message()` | ▲ | yellow |
| `hint_message()` | ↳ | dim |
| `error_message()` | ✗ | red |
| `prompt_message()` | ❯ | cyan |

**Section headings** (`worktrunk::styling`):

```rust
use worktrunk::styling::format_heading;

// Plain heading
format_heading("BINARIES", None)  // => "BINARIES" (cyan)

// Heading with suffix
format_heading("USER CONFIG", Some("@ ~/.config/wt.toml"))
// => "USER CONFIG @ ~/.config/wt.toml" (title cyan, suffix plain)
```

## stdout vs stderr

**Decision principle:** stdout carries the command's *answer*; stderr carries *narration* about producing it. The discriminating question is answer-vs-narration, not audience — `wt list` is "for the user" yet belongs on stdout because it *is* the answer. "Is this a message to the user?" doesn't discriminate, because nearly all output is.

- **stdout** → the answer, in whatever format the user selected. Data (tables, JSON, shell code, an expanded template) and `--dry-run` previews both qualify: a preview is the whole answer when nothing mutates. Human-formatted output belongs here too. Color strips automatically on a pipe (anstream), so `wt list | grep` stays safe.
- **stderr** → narration about doing it: progress, success/warning/error messages, hints, interactive prompts, and `-v`/`-vv` diagnostics.
- **directive file** → shell commands executed after wt exits (cd, exec).

The same line can flip streams between modes. `wt config shell uninstall` deletes the file, so `✓ Removed … @ ~/.zshrc` only narrates a side effect that already happened → stderr (the edited file is the answer; stdout is empty). `wt config shell uninstall --dry-run` mutates nothing, so `○ Will remove … @ ~/.zshrc` is the only answer there is → stdout. What flips isn't the wording, it's whether a side effect exists to be the answer.

For a split preview, the `--format=json` payload is the arbiter: a line json would carry goes to stdout, narration json omits stays on stderr. `wt step prune --dry-run` puts the removal plan on stdout (the same plan json emits) but keeps "Skipped young-branch (younger than 1d)" and "nothing to remove" on stderr. One case ignores all this: a preview shown *inside* an interactive prompt, such as the `?` re-preview during `wt config shell install`, is mid-prompt narration → stderr.

Examples:
- `wt list`, `wt config show` → human table/dump or `--format=json`, both to stdout
- `wt step prune --dry-run` → the removal plan to stdout (human or json); "nothing to remove" and skipped-young caveats to stderr
- `wt config shell init` → shell code to stdout (for `eval`)
- `wt switch` → status messages only (nothing to pipe)

## When to page output

Route long, human-oriented stdout through `crate::help_pager::show_help_in_pager`. The helper TTY-detects internally, so piping (`wt … | grep`) keeps working.

Page when output is human-oriented (headings, gutters, structure) and plausibly exceeds one screen. Don't page pipe-first data (tables, JSON, shell code), short output, or output already paged by a delegated tool (`git diff`).

Examples that page: `--help`, `wt config show`, `wt hook show`, `wt step {commit,squash} --dry-run`. Examples that don't: `wt list`, `wt step diff`, `wt step eval`, `--show-prompt` (pipe-first by design).

Build the whole output into a `String` first (don't stream), then:

```rust
crate::help_pager::show_help_in_pager(&out, true);
```

The helper is infallible from the caller's perspective — it falls back to
plain stdout itself when no pager is configured, stdout isn't a TTY, or the
pager fails.

## Security

The split-trust design enforces two trust levels:

- `WORKTRUNK_DIRECTIVE_CD_FILE` holds a raw path (no shell parsing), so it's
  safe to pass through to alias/hook child processes — a body that writes to it
  can at worst redirect `cd`.
- `WORKTRUNK_DIRECTIVE_EXEC_FILE` holds arbitrary shell that the wrapper
  sources verbatim, so wt scrubs this env var from alias/hook child processes.
  A hook body writing to it would inject shell into the parent session.

All directive env vars are removed from spawned subprocesses by default via
`shell_exec::scrub_directive_env_vars()`. `DirectivePassthrough::inherit_from_env()`
re-adds only the CD file for trusted contexts.

## Windows Compatibility (Git Bash / MSYS2)

On Windows with Git Bash, `mktemp` returns POSIX-style paths like `/tmp/tmp.xxx`.
The native Windows binary (`wt.exe`) needs a Windows path to write to the
directive file.

**No explicit path conversion is needed.** MSYS2 automatically converts POSIX
paths in environment variables when spawning native Windows binaries — shell
wrappers can use `$directive_file` directly. See:
https://www.msys2.org/docs/filesystem-paths/

---

# CLI Output Formatting Standards

## User Message Principles

Output messages should acknowledge user-supplied arguments (flags, options,
values) by reflecting those choices in the message text.

```rust
// User runs: wt switch --create feature --base=main
// GOOD - acknowledges the base branch
"Created new worktree for feature from main @ /path/to/worktree"
// BAD - ignores the base argument
"Created new worktree for feature @ /path/to/worktree"
```

**Avoid "you/your" pronouns:** Messages should refer to things directly, not
address the user. Imperatives like "Run", "Use", "Add" are fine — they're
concise CLI idiom.

```rust
// BAD - "Use 'wt merge' to rebase your changes onto main"
// GOOD - "Use 'wt merge' to rebase onto main"
```

**Avoid redundant parenthesized content:** Parenthesized text should add new
information, not restate what's already said.

```rust
// BAD - parentheses restate "no changes"
"No changes after squashing 3 commits (commits resulted in no net changes)"
// GOOD - clear and concise
"No changes after squashing 3 commits"
// GOOD - parentheses add supplementary info
"Committing with default message... (3 files, +45, -12)"
```

**Two types of parenthesized content with different styling:**

1. **Stats parentheses → Gray** (`[90m` bright-black): Supplementary numerical
   info that could be omitted without losing meaning.
   ```
   ✓ Merged to main (1 commit, 1 file, +1)
   ◎ Squashing 2 commits into a single commit (2 files, +2)...
   ```

2. **Reason parentheses → Message color**: Explains WHY an action is happening;
   integral to understanding.
   ```
   ◎ Removing feature worktree & branch in background (same commit as main, _)
   ```

Stats are truly optional context. Reasons answer "why is this safe/happening?"
and belong with the main message. Symbols within reason parentheses still render
in their native styling (see "Symbol styling" below).

**Show path when hooks run in a different directory:** When hooks run in a
worktree other than the user's current (or eventual) location, show the path.
Use the appropriate helper function:

1. **Pre-hooks and manual `wt hook`** — User is at cwd, no cd happens.
   Use `output::pre_hook_display_path(hooks_run_at)`.
   Examples: pre-commit, pre-merge, pre-remove, manual `wt hook post-merge`.

2. **Post-hooks** — User will cd to destination if shell integration is active.
   Use `output::post_hook_display_path(destination)`.
   Examples: pre-start, post-switch, post-start, post-merge (after removal).

```rust
// Pre-hooks: user is at cwd, no cd happens
run_hook_with_filter(..., crate::output::pre_hook_display_path(ctx.worktree_path))?;

// Post-hooks: user will cd to destination if shell integration active
ctx.spawn_post_create_commands(crate::output::post_hook_display_path(&destination))?;
```

**Avoid pronouns with cross-message referents:** Hints appear as separate
messages from errors. Don't use pronouns like "it" that refer to something
mentioned in the error message.

```rust
// BAD - "it" refers to branch name in error message
// Error: "Branch 'feature' not found"
// Hint:  "Use --create to create it"
// GOOD - self-contained hint
// Error: "Branch 'feature' not found"
// Hint:  "Use --create to create a new branch"
```

## Heading Case

Use **sentence case** for help text headings: "Configuration files", "JSON output", "LLM commit messages".

## Message Consistency Patterns

Use consistent punctuation and structure for related messages.

**Ampersand for combined actions:** Use `&` when a single operation does
multiple things:

```rust
"Removing feature worktree & branch in background"
"Commands approved & saved to config"
```

**Semicolon for joining clauses:** Use semicolons to connect related information:

```rust
"Removing feature worktree in background; retaining branch (--no-delete-branch)"
"Branch unmerged; to delete, run <underline>wt remove -D</>"  // hint uses underline
"{tool} not authenticated; run <bold>{tool} auth login</>"       // warning uses bold
```

**Explicit flag acknowledgment:** Show flags in parentheses when they change
behavior:

```rust
// GOOD - shows the flag explicitly
"Removing feature worktree in background; retaining branch (--no-delete-branch)"
// BAD - doesn't acknowledge user's explicit choice
"Removing feature worktree in background; retaining branch"
```

**Flag locality:** Place flag indicators adjacent to the concept they modify.
Flags should appear immediately after the noun/action they affect, not at the
end of the message:

```rust
// GOOD - (--force) is adjacent to "worktree" which it modifies
"Removing feature worktree (--force) & branch in background (same commit as main, _)"
// BAD - (--force) at end, disconnected from the worktree removal it enables
"Removing feature worktree & branch in background (same commit as main, _) (--force)"
```

This principle ensures readers can immediately understand what each annotation
modifies.

**Parallel structure:** Related messages should follow the same pattern:

```rust
// GOOD - parallel structure with integration reason explaining branch deletion
// Target branch is bold; symbol uses its standard styling (dim for _ and ⊂)
"Removing feature worktree & branch in background (same commit as <bold>main</>, <dim>_</>)"  // Integrated
"Removing feature worktree in background; retaining unmerged branch"                          // Unmerged
"Removing feature worktree in background; retaining branch (--no-delete-branch)"              // User flag
```

**Symbol styling:** Symbols are atomic with their color — the styling is part of
the symbol's identity, not a presentation choice. Each symbol has a defined
appearance that must be preserved in all contexts:

- `_` and `⊂` — dim (integration/safe-to-delete indicators)
- `+N` and `-N` — green/red (diff indicators)

When a symbol appears in a colored message (cyan progress, green success), close
the message color before the symbol so it renders in its native styling. This
requires breaking out of the message color and reopening it after the symbol.
See `FlagNote` in `src/output/handlers.rs` for an example — it handles flag
acknowledgment notes (like integration reasons) with proper color transitions
via its `after(color)` method, which reopens the message color after the symbol.

**Comma + "but" + em-dash for limitations:** When stating an outcome with a
limitation and its reason:

```rust
// Outcome, but limitation — reason
"Worktree for feature @ ~/repo.feature, but cannot change directory — shell integration not installed"
```

This pattern:
- States what succeeded (worktree exists at path)
- Uses "but" to introduce what didn't work (cannot cd)
- Uses em-dash to explain why (shell integration status)

See `compute_shell_warning_reason()` in `src/output/shell_integration.rs` for the
complete spec of shell integration warning messages and hints

**Compute decisions once:** For background operations, check conditions upfront,
show the message, then pass the decision explicitly rather than re-checking in
background scripts:

```rust
// GOOD - check once, pass decision
let should_delete = check_if_merged();
show_message_based_on(should_delete);
spawn_background(build_command(should_delete));

// BAD - check twice (once for message, again in background script)
let is_merged = check_if_merged();
show_message_based_on(is_merged);
spawn_background(build_command_that_checks_merge_again());  // Duplicate check!
```

## Warning Ordering

**Core principle:** Messages about state discovered during evaluation
(warnings, info notices) appear **before** the action message that follows
from that evaluation.

When a command evaluates state, discovers something unexpected, and proceeds
anyway, that message comes first:

```
▲ Auto-staging 1 untracked path:
   ┃ notes.md
◎ Generating commit message...
```

Not:

```
◎ Generating commit message...
▲ Auto-staging 1 untracked path:
   ┃ notes.md
```

Warnings that result from the action itself (something failed during execution)
naturally come after the action.

## Message Types

**Success vs Info:** Success (✓) means something was created or changed. Info
(○) acknowledges state without changing anything.

| Success ✓                               | Info ○                                |
| --------------------------------------- | ------------------------------------- |
| "Created worktree for feature"          | "Switched to worktree for feature"    |
| "Created new worktree for feature"      | "Already on worktree for feature"     |
| "Commands approved & saved"             | "All commands already approved"       |

The same rule governs standalone symbols used as per-row markers in listings:
✓ marks a completed action, never a state. A listing that reports per-item
status marks it with ○ (state acknowledged) or ❯ (awaiting approval/user
input) — the shared vocabulary of `wt hook show` and `wt config approvals
list`. Reserve a per-row ✓ for outcomes of work the command just performed
(shell-install action lines, `-v` subprocess trace glyphs).

**Hint vs Info:** Hints suggest user action or provide additional non-essential
context (supplementary details the user doesn't need but may find useful). Info
acknowledges state without changing anything.

| Hint ↳                                          | Info ○                                |
| ------------------------------------------------ | ------------------------------------- |
| "To continue, run `wt merge`"                    | "Already up to date with main"        |
| "Commit or stash changes first"                  | "Skipping hooks (--no-hooks)"         |
| "Branch can be deleted"                           | "Worktree preserved (main worktree)"  |
| "Failed command, exit code 128:"                   |                                       |

**Warning placement:** When something unexpected happens, warn somewhere. Where
depends on the nature of the issue:

```
Is it unexpected?
├── No → Silent (e.g., gh not installed when no GitHub remote)
└── Yes → Warn somewhere:
    ├── Immediate impact OR temporary → Inline (warning_message or in-band indicator)
    ├── Persists until user action → wt config show (can be checked later)
    └── Not user-fixable → log::warn! (developer diagnostics)
```

**
