Output System Architecture
SkillDocs & knowledgeCLI 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.
No other account needed.
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:
- Shell wrapper creates a temp file via
mktemp - Shell wrapper sets
WORKTRUNK_DIRECTIVE_CD_FILE - wt writes a raw path to the file
- 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):
| Function | Purpose |
|---|---|
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):
| 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):
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-runpreviews both qualify: a preview is the whole answer when nothing mutates. Human-formatted output belongs here too. Color strips automatically on a pipe (anstream), sowt list | grepstays safe. - stderr → narration about doing it: progress, success/warning/error messages, hints, interactive prompts, and
-v/-vvdiagnostics. - 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 stdoutwt step prune --dry-run→ the removal plan to stdout (human or json); "nothing to remove" and skipped-young caveats to stderrwt config shell init→ shell code to stdout (foreval)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_FILEholds 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 redirectcd.
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:
-
Stats parentheses → Gray (
[90mbright-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)... -
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:
-
Pre-hooks and manual
wt hook— User is at cwd, no cd happens. Useoutput::pre_hook_display_path(hooks_run_at). Examples: pre-commit, pre-merge, pre-remove, manualwt hook post-merge. -
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)+Nand-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:
| Issue | Why inline |
|---|---|
| Rate limit during CI fetch | Temporary — won't be there next time |
| Network timeout | Temporary — retry might work |
| Hook failed during operation | Immediate 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:
| Issue | Why config show |
|---|---|
gh not authenticated | User runs gh auth login |
| Shell integration misconfigured | User updates shell config |
| Config syntax errors | User fixes config file |
log::warn!() for issues users cannot fix. These help developers debug but
shouldn't clutter user output:
| Issue | Why log::warn! |
|---|---|
| JSON parse error (API changed) | Requires code fix |
| Internal invariant violated | Developer 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