Appearance
Progress Timeline
On an interactive terminal, the worktree lifecycle commands — daft go, daft start, daft remove (and their git-worktree-* forms), daft clone, and multi-worktree daft exec — narrate their work as a plan-then-execute timeline: the full ordered list of steps renders up front, each step fills in place as daft works, and the finished rail persists in your scrollback as a receipt.
┌ Starting daft-652/cool-feature ← main
│
✓ Fetched remote origin (1.1s)
✓ Set up tracking
✓ Created branch ← origin/main
✓ Checked out branch
✓ Created worktree daft-652/cool-feature
✓ Pushed → origin/daft-652/cool-feature (1.8s)
│
├─ copied paths from 'main'
│ ✓ target/ 12 paths · 1.4 GB · reflinked · 0.4s
│ ○ node_modules/ nothing to copy yet
│
├─ shared files
│ ✓ .env
│ ✓ .claude/settings.json
│
├─ post-create hooks
│ ✓ prepare-db (2.1s)
│ ✓ bun-install (2.9s)
│ ↻ check-todos background
│
└ Ready in 6.3sReading the rail
| Glyph | Meaning |
|---|---|
○ | Pending (dim), or an expected skip with its reason |
⠹ | The step currently running (spinner) |
✓ | Done — past-tense label, dim duration when the step took ≥ 1s |
✗ | Failed — the label stays imperative (the fact never happened) |
↓ | Skipped for an attention-worthy reason (e.g. repo not trusted) |
├─ | A section anchor (shared files, hook phases, multi-branch remove's branches) |
↻ | A hook job handed to the background coordinator |
Rows belonging to a section render tucked inside the rail (│ ✓ .env), so the rail stays a continuous wire and each ├─ anchor visibly carries its children — in the pending plan, while running, and in the finished receipt. A hook phase that will open as a section already owns its blank rail lines in the committed plan, so the plan carries the receipt's rhythm and no row shifts when the section starts to fill.
Color follows one grammar. State lives in the glyph (green done, bold-red failed, yellow attention, cyan spinner) and daft's own vocabulary stays plain, with section headings bold. Subjects wear identity inks that never change with state — so the committed plan is as readable as the receipt: remote names and refs (origin, ← origin/master, → origin/x) are cyan, filesystem entities are manila, shared files are violet, and background work is blue. Manila types the entity rather than the spelling, so a row whose subject is a worktree wears it whether the row names that worktree — the branch it is for, or a sandbox's directory name — or spells the directory it occupies. The exceptions are deliberate: hook job names take their outcome's color (the scheme the standalone hook renderer's summary also speaks), failure details and skip reasons always render plain, and a dimmed row — pending glyphs, expected skips, (not run) — never keeps an identity ink.
The rail opens the moment the command starts (after any pre-flight prompts) as a single collapsed line — the active spinner, the header's own text, and the ticking stopwatch on its tail. That line is the whole rail until a plan gives it a body: the
┌ │ └frame appears only when the command has resolved work worth framing, expanding the line in place with the committed plan beneath it. The text follows the resolve phase —daft cloneruns its whole network clone under the collapsed line, flips toResolving branches, and commits a plan led by the already-done✓ Cloned repositoryrow;daft goflips toFetching originwhile adaft.checkout.fetchround-trip runs. A prompt that must own the terminal mid-resolve (the first-clone layout prompt) makes the line step aside tracelessly and return, on the same phase, once answered. A run that resolves into a navigation early-exit, a hop to another cataloged repo, or a resolve-phase error collapses the line without a trace and keeps its single-line response.The header names the resolved intent (
Starting <branch> ← <base>); the footer closes the rail with the outcome and total duration. While the command runs, the pending footer is a stopwatch — a dim elapsed counter (└ 1.2s) ticking from the moment the rail opens until the outcome replaces it.With
daft.checkout.fetchon,daft goruns the fetch under the collapsed line and commits its plan only once the branch is known to exist — leading with the already-done✓ Fetched remoterow, the branch's provenance (← origin/x,tracking origin/x,local only) already resolved onto theCheck out branchrow. A name the fetch fails to reveal is never this rail's work: the line dissolves tracelessly, and whatever the name turns out to be — another cataloged repo, a tag or commit that opens a sandbox, a branch--start(ordaft.go.autoStart) creates, or a plain error — owns the run's only output. A failed fetch warns, turns its row yellow (↓ Fetch remote failed — continuing with local refs), and the command proceeds on the refs it has. With the fetch off, the branch probe precedes the plan and an unknown branch keeps the plain error.daft startand forge targets (daft go pr:123) plan the fetch as work committed before the round-trip instead —daft startopens its rail with theFetch remoteandSet up trackingrows, and a forge miss falls back to the PR/MR head ref rather than failing to find a branch. Fordaft startthe header names the requested base; when the fetch reveals a fresher remote ref, theCreated branchrow carries the resolved provenance (← origin/main).The rail lists only work that happens. A step known to be off at planning time (push with
daft.checkout.pushoff or--local) plans no row, and a step that resolves as a no-op (carry with a clean tree) removes its row — the finished rail is a receipt of what daft actually did. Attention-worthy skips are the exception and stay visible.Remote indicators appear only while remote interaction is in scope:
← origin/x(created from the remote),→ origin/x(pushed),tracking origin/x, or remove's dimno remote branchnote when remote deletion is on but the branch has no upstream. When configuration takes remotes out of scope — theremote-syncbehavior set tooff,daft.branchDelete.remoteoff (the default), or--local— the rail never mentions them, exactly as an unconfigured push plans no row.Shared files get their own section under a
├─ shared filesanchor, after the copied paths — daft-managed links go on top of whatever bulk content the copy brought: one receipt row per declared path stating its state.✓means the symlink landed;○ already linkedand○ materializedare the quiet no-ops; a path never collected into shared storage renders the yellow↓ … missing from shared storagerow with thedaft shared syncremedy, and a real file in the way gets thedaft shared linkremedy. The section never silently ignores a declaration it could not honor.Copied paths get the same treatment under a
├─ copied paths from 'master'anchor, immediately before the shared-files section. The anchor names the worktree the caches came from, because that is a ranked decision rather than something the command line shows — the base branch's worktree when it has one, otherwise any worktree at the identical commit (· same commit, plus, where you areor, warmestwhen a tie had to be broken), otherwise where you ran the command (· the same-commit worktree is emptywhen the ladder passed over a match that carried none of the declared caches). Below it, one row per declared entry, never per expanded glob match, so a**/dist/declaration stays one row and reports its fan-out in the annotation (3 paths · 1.2 GB · reflinked · 0.3s;part reflinkedwhen only some matches cloned,· 2 unreadablewhen the expansion could not read everywhere). Exactly three skips are dim, because they are the stage working as designed:nothing to copy yet,already present,matched nothing. Every other outcome is yellow — a declaration daft refused (must be gitignored — tracked content is never copied), could not size (2.1 GB — over the 1 GB max_size), or could not carry out (failed — …). The row's label is the entry, so its phrase never repeats it; when a glob expands, the phrase names the one match that offended. No copy row is ever red: the stage is an optimization, and a cache that did not copy has not cost the user the worktree they asked for.daft removelists steps in true execution order — the remote branch is deleted first (it is the hardest to recreate), then the worktree, then the local branch. Multi-branch removals group rows under├─branch anchors. Its hook rows are planned only when the phase has hooks discoverable at plan time: a repository configuring noworktree-post-removehooks plans nopost-remove hooksrow at all.Lifecycle hooks appear as a plan row framed by its section's rail gaps; when they actually run, the row becomes a
├─ post-create hookssection in place, with one receipt row per job. While a job runs, its latest output line rides the spinner as a dim annotation — one line of liveness per job. A finished job resolves green with the usual dim duration; a failed one turns its row red and its captured output prints below the rail footer. Jobs excluded with--skip-hooks(and jobs skipped because a dependency failed) render yellow↓rows; jobs skipped by their ownskip:/only:conditions leave no trace, and a whole phase skipped that way vanishes with them. Background jobs get a blue↻ name backgroundreceipt —daft hooks jobsmanages them from there.-x/--execcommands are planned where they run: last, after the post-create hooks. One row per-xoccurrence, labelled with the command exactly as you typed it (flattened to one line, so a pasted multi-line snippet stays one row), under an├─ execanchor once there are two or more — so two identical commands stay two rows. A command owns the terminal for its whole run (-xinherits stdio and may be interactive), so its own output prints above the rail and its row then resolves green, or red with the exit status (✗ npm ci exit 1). A failure stops the sequence, and the commands that never got their turn say so (↓ cargo test not run). The worktree exists and your shell still moves into it, so the footer readsReady with failures in 2.4srather than claiming the creation failed.This is the worktree-creation family —
checkout/go,start, a fork, a sandbox visit that materializes. Everywhere else-xkeeps the single-lineExecuting: <cmd>record it has always had: navigating to an existing worktree commits no plan and so has no rail at all, anddaft clone -x,daft init -x, andstart --with-related's post-fan-out sequence run their commands outside the rail's lifetime — after the receipt, not inside it.The
pre-pushgate is git's hook, not daft's — git dispatches it insidegit pushand daft sees one output stream. When that stream is a hook manager (lefthook 2.x — every released 2.x version is covered), daft recognizes its line grammar and renders the manager's jobs as first-class rows under the├─ pre-push hooksanchor, and folds the manager's identity into that header (├─ pre-push hooks lefthook v2.1.10) — a persisted line that stays on screen while the run works and in scrollback after. daft reads the manager's config for the job list, so every job — including one that runs for minutes — is a live spinner with a running elapsed timer from the moment the manager engages, not only once its output finally arrives. (The roster is read up front, so a manager configured to run serially still shows all its jobs from engagement: a not-yet-started job spins with a timer counting from the phase start, not its own — a pipe gives no per-job start signal to key it to.) The instant a job's output block flushes (in default piped mode that is the job's completion), its row stops spinning and settles to a neutral grey✓— finished running, verdict pending — so a job that finished early no longer looks busy until the whole run ends. Managers report per-job pass/fail and the official duration only in their end-of-run summary, so each grey✓resolves there to its confirmed receipt: green✓with the duration, or red✗with the failure dump scoped to that job (error: hook job 'unit tests (related)' failed:) instead of the whole run. The confirmed verdicts land together at the summary because that is the only moment the manager reveals them. In verbose mode the closing note carries the phase total (└ all jobs in 42.4s); the manager's identity already lives in the header, so the note no longer repeats it. Output daft does not recognize — a plain script, husky, an unknown tool — passes through untouched as the singlepre-pushjob, exactly as before; jobs the manager never resolved (killed mid-run) settle with the push verdict so no row is left spinning, and the push verdict itself always comes from git's own result, never from recognized output.daft.hooks.output.parseManagers=falseturns recognition off entirely.daft sync --pushreports the same structure in its table: the manager's jobs as-vsub-rows, the hook line annotated with the manager's identity, and the post-runHooks:report naming the failing job (pre-push · unit tests (related) failed) with only that job's output.Pass
-v— or setdaft.hooks.output.verbose— to thread each job's log under its row. The section anchor gains the hook key and engine version (├─ post-create hooks worktree-post-create · daft v1.18.1), and each job's output hangs from its glyph column on an inner thread:│ ✓ prepare-db (2.1s) │ │ ❯ ./scripts/prepare-db.sh │ │ applying migration 0 │ │ applying migration 1 │ │ │ ✓ bun-install (2.9s) │ │ ❯ bun install │ │ resolving package cluster 11The thread opens with the dim
❯ <command>provenance line, shows a rolling window ofdaft.hooks.output.tailLineslines while the job runs, and the receipt keeps every line — grey under a job that succeeded, default ink under one that failed (evidence stays loud),(no output)when it printed nothing. Each thread closes with an empty thread line (│ │), so consecutive blocks keep their own air — live and in the receipt — while the rail's lone│stays a section boundary. The section closes with its own rail end — a dim└ all jobs in <t>total — and a job still silent afterdaft.hooks.output.timerDelayseconds shows a dim elapsed counter until its first output. A failed job's exit status still prints after the footer (error: hook job '<name>' failed (exit code: N)) — but not its output, which already sits inline. When nothing is configured to run, the hook row disappears — anddaft removegoes further: its hook config sources are on disk and exact before the plan commits, so the row is never planned. Skips worth noticing (untrusted repository,--skip-hooks all) render the yellow↓row instead.If a step fails, later steps persist as dim
(not run)rows and the footer reportsFailed after <t>— the receipt shows exactly how far the command got.
Running commands across worktrees
Multi-worktree daft exec renders on the same rail, with one row per targeted worktree (or, for a -x pipeline of several commands, a ├─ group per worktree with one row per command):
┌ Running mise clean in 4 worktrees
│
✓ master (3.2s)
✓ daft-335/feat/visitor-config (4.5s)
✗ daft-518/feat/test-runner-output-improvements exit 1
│ [clean:tests] rm -rf target/tmp
│ error: Permission denied (os error 13)
│
✓ daft-529/exec-show-output (12.1s)
│
└ Finished with failures in 12.4s- Workers run concurrently, but the receipt persists in plan order: a row that finishes early shows its outcome in place immediately and waits, in the scrollback, for the rows ahead of it. The header names the resolved scope (
in N worktrees,in N reposfor--all-repos,in N related worktreesfor--related); the footer reportsDone in t,Finished with failures in t(all ran, some failed),Failed after t(a--sequentialrun stopped early), orCancelled after t. - While a worker runs, its latest output line rides its row, dim. A failed or cancelled worker always threads its full captured output under its row; a successful worker stays a compact row. A row cancelled by
Ctrl-Cshows the yellow⊘face; the↓face marks a matched branch with no worktree. - Pass
-v(--verbose) to thread every worker's output — grey under a success, a rolling window while it runs — using the samedaft.hooks.output.tailLineswindow the hook rail uses. A worker's full output reaches scrollback only once the rows ahead of it in the plan drain; its✓/✗/⊘outcome is never delayed. Nothing prints below the footer. You can also pressvmid-run to switch either way — see Live keys. - A single explicit-target run (
daft exec feat/auth -- claude, or a bare--repo) inherits stdio directly and renders no rail, so interactive programs work unchanged. A fan-out —--all, a glob,--all-repos,--related, or several positionals — renders the rail even when it resolves to a single live worktree (any orphan branches ride along as↓rows), rather than collapsing to pass-through. When stdout is redirected,daft execstill writes its captured-output dump there (failures only, or every worker with-v) while the rail narrates on stderr.
When the timeline does not render
The timeline is an interactive-terminal presentation. In every other mode daft prints exactly the output it printed before the timeline existed:
- Non-interactive stderr (pipes, CI logs) — plain result lines.
NO_COLOR,TERM=dumb— plain result lines (the live region requires color support; this matches the previous spinner's behavior).--quiet— warnings and errors only.- Navigation early-exits —
daft goto an existing worktree anddaft go -remain single-line responses; there is no plan to show (the just-opened planning face collapses without leaving a trace). - Single explicit-target
daft exec— inherits stdio directly (so interactive programs work); a fan-out or multi-target run on a non-interactive stderr prints the same summary rows and output dump it always did.
daft prune, daft repo remove, and multi-branch daft clone's satellite phase keep their inline operation table, which already shows all rows up front and fills them in as work completes. In daft sync --push, that table also surfaces the push resource governor: a push held back under memory pressure shows a dim held: memory (or held: capped / held: frozen / held: retry) instead of running immediately, and a post-run summary line reports the total ("2 pushes throttled 14s to preserve memory headroom").
Live keys
While the rail is on screen, the stopwatch footer offers what you can press:
└ 4.2s v verbose · ^C cancelv toggles verbose output for the run in progress. Start terse and press v when a job starts looking interesting, or start with -v and press v to quiet it back down — verbosity is a decision you make while watching, not one you commit to before the run starts.
The toggle takes effect immediately for rows still running and for every receipt printed from then on. Rows that already finished are a different matter: the rail is append-only, so their receipts stay exactly where they printed. Turning verbose on re-emits the logs of finished rows that printed compactly as a fold-out block below, headed by a repeat of the receipt line:
✓ feat/auth
○ verbose on — replaying 1 finished row
✓ feat/auth
│ cargo test --lib
│ test result: ok. 214 passed
│The verbose on note appears only when there are finished rows to fold out. A flip with nothing to replay — and every verbose off — changes the live rows alone and adds no line to scrollback, so repeated toggling never piles up notes; the footer hint (v verbose / v quiet) is what always shows the current setting. Each log folds out once, so toggling back and forth never repeats it. Failed rows are not replayed — their output already threaded when they failed. Turning verbose off collapses the live windows and leaves everything already printed alone.
The hint and the toggle are terminal-only: with output redirected, in CI, or under --quiet there is no live region, no key listener, and no change to what daft prints. The toggle also does not change the captured-output dump daft exec writes to a redirected stdout — that follows the -v flag you passed, so a script's output does not depend on what you pressed. Only daft exec's rows replay; hook-job rows (worktree create/remove, daft run) follow the new density from the next line they print onward.
Pressing Ctrl-C mid-run collapses the live remainder of the rail and exits with status 130; everything already completed stays in your scrollback. A daft exec run interrupts cooperatively instead: the first Ctrl-C stops the running commands (SIGTERM), a second forces them (SIGKILL), and the rail closes as a Cancelled receipt. This is unchanged by the key listener: Ctrl-C reaches daft as a real signal, not as a keystroke.