Output System Architecture

SkillDocs & knowledge

CLI output formatting standards for worktrunk. Load before editing any code that calls warning_message, hint_message, error_message, info_message, eprintln, or println, or that produces strings the user will see (CLI help, progress UI, snapshot text). Documents ANSI color nesting rules, message patterns, and output system architecture.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Output System Architecture skill

What this skill tells your AI

The instructions your AI receives, as published by max-sixty/worktrunk in .claude/skills/writing-user-outputs/SKILL.md and read by ahel’s review.

Shell Integration

Worktrunk uses one file-based directive for shell integration:

  1. Shell wrapper creates a temp file via mktemp
  2. Shell wrapper sets WORKTRUNK_DIRECTIVE_CD_FILE
  3. wt writes a raw path to the file
  4. Shell wrapper changes directory to that path after wt exits

--execute always launches its external program directly from wt, with the selected worktree as its working directory. It does not use a directive.

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.

// 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):

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()?;

src/ holds two crates, and the path differs between them: worktrunk::styling from the binary's modules — the ones src/main.rs declares (commands, cli, display, …), which the examples throughout this skill are written for — and crate::styling from the library's, the ones src/lib.rs declares (git, config, shell_exec, …), where worktrunk:: does not resolve at all. The guard tests accept either, so the compiler is the only thing that tells you the path is wrong for the file.

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.

A write that names no macro misses that scan entirely, since the scan looks for the name: writeln!(std::io::stderr(), …), std::io::stderr().write_all(…), a locked handle, a bound one. check_raw_stderr_writes_go_through_anstream refuses those shapes too, with its own RAW_STDERR_ALLOWED_PATHS — currently just progress.rs, whose spinner holds one lock across a frame (anstream's stderr() has none) and is tty-gated, so nothing of its output ever reaches a redirected stderr. The shape to watch for is a message rendered in one place and printed several layers below, where the printer has no idea it is handling narration: Cmd::delayed_stream's progress line is the example, and a raw handle there made it the only colored line in a redirected wt switch log.

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):

FunctionPurpose
change_directory(path)Shell cd after wt exits (writes to directive file if set)
execute(argv)Run an external program in the selected worktree
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):

FunctionSymbolColor
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):

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 → the raw cd path consumed after wt exits.

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:

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

  • 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.

All directive env vars are removed from spawned subprocesses by default via shell_exec::scrub_directive_env_vars(). DirectivePassthrough::inherit_from_env() re-adds the CD file where a nested command may redirect the parent shell.

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.

// 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.

// 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.

// 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).

// 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.

// 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:

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

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

"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:

// 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:

// 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:

// 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:

// 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:

// 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)

Inline warnings for issues affecting the current command:

IssueWhy inline
Rate limit during CI fetchTemporary — won't be there next time
Network timeoutTemporary — retry might work
Hook failed during operationImmediate impact on this command

wt config show for issues that persist until the user fixes them. These don't need to interrupt every command — users can check diagnostics when investigating:

IssueWhy config show
gh not authenticatedUser runs gh auth login
Shell integration misconfiguredUser updates shell config
Config syntax errorsUser fixes config file

log::warn!() for issues users cannot fix. These help developers debug but shouldn't clutter user output:

IssueWhy log::warn!
JSON parse error (API changed)Requires code fix
Internal invariant violatedDeveloper bug

Command suggestions in hints: When a hint includes a runnable command, use "To X, run Y" pattern. End with the command for easy copying:

// GOOD - command at end for easy copying
"To delete the unmerged branch, run wt remove feature -D"
"To rebase onto main, run wt step rebase or wt merge"

// GOOD - recovery command after shadowing a remote branch
"To switch to the remote branch, delete this branch and run without --create: wt remove --foreground feature && wt switch feature"

// BAD - command without context
"wt remove feature -D deletes unmerged branches"

// BAD - command not at end (hard to copy)
"Run wt switch feature (without --create) to switch to the remote branch"

For general action guidance without a specific command, direct imperatives are clearer:

// GOOD - direct imperative for general guidance
"Commit or stash changes first"
"Run from inside a worktree, or specify a branch name"

// VERBOSE - "To proceed" adds nothing
"To proceed, commit or stash changes first"

Description + command in single message: For warnings/errors that include a recovery command, join with semicolon. Use <bold> for commands in warnings/errors (only hints use <underline>):

// Warning with inline recovery command (bold for commands)
warning_message("Failed to restore stash; run <bold>git stash pop {ref}</> to restore manually")
warning_message("{tool} not authenticated; run <bold>{tool} auth login</>")

// For longer suggestions, use separate hint message (underline for commands)
warning_message("Failed to restore stash")
hint_message("To restore manually, run <underline>git stash pop {ref}</>")

Multiple suggestions in one hint: When combining suggestions with semicolons, put the more commonly needed command last for easy terminal copying:

// GOOD - common action (create) last, easy to select and copy
"To list branches, run wt list --branches; to create a new branch, run wt switch feature --create"

// BAD - common action buried, harder to copy
"To create a new branch, run wt switch feature --create; to list branches, run wt list --branches"

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
8k
Forks
273
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
writing-user-outputs
Source
github.com/max-sixty/worktrunk