Using the TUI
vincent with no arguments opens the terminal UI, starting a daemon if none is
running. It is a pure API client: it holds no state the daemon does not have,
and quitting it never affects work.
vincent # opens the TUI
- The first run
- Layout
- The board
- Acting on several tasks at once
- Task detail
- Answering a question
- The takeover screens
- The command palette
- Every key
- Mouse, selection and paste
- When the daemon is unreachable
The first run
The very first launch shows a one-time notice: agents run full-auto by
default and can execute arbitrary commands as you. Acknowledge it once and it
does not return. The acknowledgment is stored in {data_dir}/tui.json.
It is worth reading rather than dismissing — Security model is the longer version.
Layout
The home screen is the task board and nothing else:

enter opens the selected task in a separate full-screen workspace. That
workspace has five full-view tabs — Steps & Attempts, Task Details,
Output, Diff, and Workflow — so the surface being read gets the
whole terminal. tab advances through them, shift+tab goes back, and 1–5
jump directly. esc returns to the board.
New task, projects, workflows, daemon, and — for GitHub projects — pull
requests are full-screen takeovers too. esc
closes one layer at a time (popup → task/screen → selection → filter) and
never quits.
At 128×24 and above, New task, Projects, and Workflows use that room as a guided two-pane surface: progress or resources stay in a narrow rail, and the current decision gets the rest of the screen. Below that size they fall back to the compact form, table, or registry. Resizing does not move the cursor or close the picker, editor, project form, workflow expansion, or graph you were using.
The board
One row per task: id, project, title, state, current step k/n with its name,
elapsed, and cost so far. The header shows daemon status, agent availability,
running-versus-cap counts, and how many tasks need a human.
Three behaviors matter:
- Tasks waiting on a human are pinned to the top with a distinct badge —
awaiting_input,awaiting_gateandblocked.!jumps to the next one from anywhere. - The terminal bell rings when a task enters
awaiting_input, so most terminals flash or badge the window even when it is not focused. - A task waiting on a clock says when it resumes. A task whose agent hit a
usage limit — or whose failed step is pacing its next attempt with
retry_backoff— isqueuedlike any other, but its state cell readsqueued → 14:20, the time vincent will try it again, on its own. It holds no slot and needs nothing from you; the detail header names the reason in full (queued · usage limit → 14:20,queued · retry backoff → 14:20). See Troubleshooting andretry_backoff. - The header badges the agent, not just the task. An adapter vincent has
watched run out reads
claude ⏳14:20in place ofclaude ✓, and stays that way until a step on that adapter succeeds — so a board full ofqueuedrows says which window they are all waiting on. An adapter that reports its own quota is badged the same way once that reading hits 100%, and reads a bare⏳when the source named no reset, since⏳00:00would be a time vincent invented. The badge is a statement, not a brake: admission is unchanged and nothing is withheld.
/ filters by id, title, project or state; tab commits the filter, esc
clears it, and enter opens the selected task.
A cell too long for its column wraps rather than disappearing. The title,
the state, the step and the status carry across up to three lines of the same
row, so awaiting_children (2 blocked) and a step’s own message are readable
without opening the task. Every row on a board is the same height — as tall as
the tallest row in the list, and never more than three lines — so a board where
nothing overflows is one line per task, exactly as before. The list, not the
part of it you can see: one long title far down the board makes the rows above
it tall too, and a filter that hides it makes them short again. What still does
not fit at three lines ends in …. Clicking any line of a row selects that row,
and j/k move a task at a time whatever the height. The id, elapsed, cost
and the marker column do not wrap, and neither do project and workflow: those
two are names you scan down, which a fourteen-cell wrap makes unreadable, so
under width pressure they are dropped instead.
The title has a ceiling. It takes whatever the fixed columns leave, up to a
comfortable width; past that the extra room goes to STEP and then STATUS —
the two columns whose content actually outgrows them — and only what neither
can use comes back to the title. So a 200-column board shows
3/7 green · loop 4/10 · repair 2/3 whole instead of spending the room on a
title’s trailing blanks. A loop rollup too wide for the column it is given
drops clauses from the tail rather than wrapping — the body step goes first,
then the for_each item, then the counter, which is the last thing to survive.
A wide terminal also gets a STATUS column: what the task’s newest step run
said about itself, if it said anything —
compiling internal/store, 3 tests red. It is set by the step, not by
vincent, through vincent status, so it
is empty until a workflow asks for it; see
Reporting status from a step.
It is the first column dropped when the terminal narrows, and it needs a
comfortably wide title to be admitted at all — so a board that has never seen it
is a board that has the width for everything else instead.
Elapsed on the board is wall clock from the task’s start. That is deliberate: a task idle on a human for 35 of its 40 minutes must not read as “5m” on the board whose job is to flag it. The per-attempt figures in the timeline are the other measure — active time, with the excluded wait shown beside it rather than silently subtracted.
Grouping
The rows are grouped by project, and by workflow within a project, out of the box:

- The header shows the group’s task count, and the needs-attention badge when it holds any — a group can never be the reason you missed something waiting.
- Grouping does not reorder anything. The sort is what it always was, and a group sits where its first task does, so the group holding the oldest thing waiting on a human is the top group.
- A grouped level loses its column — the header already names it — and the
width it frees is spent on the row: on the title first, and once the title
has reached its ceiling, on
STEPandSTATUS. - An open header is a label: the cursor steps over it, and clicking it selects nothing.
g cycles project›workflow → project → workflow → flat for the session. The
panel title names the grouping whenever it is not the configured one.
Folding groups
On an installation with six projects on it, most of the board is not what you are working on. Fold the rest away:
| Key | Does |
|---|---|
← |
Collapse the group you are in. Press it again on the header it closed to fold the group around that |
→ |
Expand the collapsed group under the cursor, one level |
C |
Collapse every group |
O |
Expand every group |
A collapsed header shows ▸ instead of ▾, and it keeps everything that made
the group worth reading at a glance: the task count, the ! needs-attention
badge, and how many of its tasks are selected. The cursor rests on a collapsed
header — that is what makes it a row rather than a label, and it is how ←
and → reach every nesting level. It is not a task, so the action keys, space
and enter do nothing there.
Three things mean a fold can never hide work waiting on you:
- the header’s badge and count survive the fold;
!(jump to the next task needing a human) opens whatever group it lands in;- a collapsed group opens by itself the moment a task inside it starts waiting for input.
Folds are remembered across restarts, in {data_dir}/tui.json
(files). They survive g, a filter and a reconnect,
and a group is forgotten when its project or workflow leaves the board. V
still selects tasks inside a collapsed group — the selection is a set of tasks,
not of rows. With group_by: [] there are no groups, so the four keys do
nothing. A fresh install has nothing folded.
Set the grouping you start with in config.yaml
(tui.board.group_by); []
gives you one flat list.
Acting on several tasks at once
Archiving yesterday’s finished work one row at a time is the same keypress ten times with a confirmation between each. Select the tasks instead:
| Key | Does |
|---|---|
space |
Select the task under the cursor (again deselects) |
V |
Select every task the filter is showing — or clear that selection |
L |
Expand the selected fan-out’s lanes as indented rows under it, or collapse them again. Lanes are hidden from the board otherwise, and stay out of every count |
esc |
Clear the selection |
While anything is selected, a ✓ appears beside those rows, the panel title
counts them (Tasks — 5 selected), and the action keys act on the whole
selection:

The count beside each key is how many of the selected tasks that action can
actually move — an action shows up when some selected task accepts it, and the
ones that do not are left alone. So a selection holding four finished tasks and
one still running offers A archive (4), and the running one stays where it is.
The rest of the behavior follows from what a selection is:
- It is a set of tasks, not of rows. Filtering, regrouping and refreshing do not change it — that is why the count is in the title, so a selected task the filter is hiding still says it is coming along.
- One confirmation for the batch.
Aasks once, about all of them. Behind the scenes vincent sends one ordinary action per task, so the daemon sees nothing special; you get one line back: how many moved, and the first refusal named if any refused. - Uncommitted changes still re-prompt. A bulk archive archives the clean
worktrees and asks again about only the dirty ones —
2 of 5 selected tasks have uncommitted changes. - What succeeded leaves the selection; what failed stays in it, so a retry needs no re-selecting.
- The keys work from any panel. Whatever has focus, the footer is counting
the selection, so that is what
Aacts on.
Task detail
enter opens the selected task on Steps & Attempts, the default tab. It
lists every attempt of every step with its duration, tokens and cost. Selecting
an attempt chooses what the separate Output tab shows; the selection stays
put while you move between tabs. Press enter on an attempt to jump straight
to its output.
Task Details is the complete task inspector: title, description, declared
fields, state, project, workflow and its recorded origin, branch and worktree,
priority, tokens and cost, lifecycle timestamps, queue/block information,
pending input, fan-out/loop metadata, captured GitHub issue, available actions,
and the task’s workflow-step snapshot. Its left sidebar selects one section at
a time, so unrelated metadata does not compete for the screen. Use ↑/↓ or
the mouse to choose a section and pgup/pgdn to scroll long section content;
the inspector never edits anything.
Its GitHub pull request section follows the captured issue and shows one of three things: the pull request linked to this task with its live state, the reason the integration is unusable, or — when nothing is linked — the offer to open one. Two keys work there:
| Key | Does |
|---|---|
o |
Open this task’s pull request in a browser |
P |
Push this task’s branch to origin and open its pull request — the title, body and draft flag are editable first |
P opens a small popup with the title and body vincent guessed from the task,
plus a draft toggle — all three editable:
| Key | Does |
|---|---|
↑ / ↓ |
Move between the title, the body and the draft row |
enter |
Edit the row under the cursor, or toggle the draft row |
space |
Toggle draft / ready for review, on the draft row |
e |
Write that row in $EDITOR instead |
ctrl+s |
Push the branch and open the pull request |
ctrl+o |
Open GitHub’s own new-pull-request page with this prefill instead |
esc |
Close without sending anything — the draft is discarded |
Inside a field enter is a newline (a pull-request body usually wants more than
one line), ctrl+s keeps the text, and esc discards it. A title is required:
ctrl+s without one says so rather than sending something GitHub cannot use.
ctrl+s is the one thing vincent writes to GitHub. It pushes committed work
only — anything uncommitted in the task’s worktree is not in the pull request,
which the popup says above the rows — and it never force-pushes: a diverged,
protected or rejected push creates no pull request and changes nothing on the
remote. Pressing it twice sends once.
If there is no credential with write scope, or GitHub refuses the create, the
branch is still pushed and the popup points you at ctrl+o, whose page now
works because the branch is on the remote. The TUI itself never talks to GitHub:
every call is the daemon’s.
o and P are absent unless at least one registered project’s GitHub
integration is usable. Linking lives on the pull-requests screen below;
unlinking lives there and on the Pull Request tab.
Output gives the selected attempt’s live tail or historical transcript the
entire view. Its selector names the attempt and its position in the task; use
←/→ (or h/l) to show another attempt without returning to the timeline.
Pull Request is the sixth tab, and the only one that is sometimes not
there: it appears when the task has a pull request linked and github.enabled
is on. It carries the pull request’s facts and one row per check on its head
commit — name, state, and the check’s own page — read live from the daemon on
open, on a reconciler tick, on its own poll and on r. Nothing about a check
is stored, because a stored check result reads exactly like a current one
while being wrong. ↑/↓ select a row, c opens it in a browser, o opens
the pull request and u unlinks it from the task; the refusal is sticky, so
the reconciler will not link it again. Nothing here writes to GitHub.
Diff gives the task’s file-grouped git diff the entire view. Workflow draws the workflow this task ran as a control-flow graph with its run state on it; it is documented beside the workflows screen’s graph, under Workflows below.
The header names the task’s workflow and, in brackets, where that definition
came from: adhoc (built-in), adhoc (project .vincent/workflows/adhoc.yaml),
release (global workflows/release.yaml), api (derived from task 41) for a
fan-out lane, or adhoc (unknown) for a task created before vincent recorded
it. A project or global file shadows a built-in of the same name, so the name on
its own does not say which one ran.
It sits late in the header — after the branch, before the run’s cost — so a
narrow pane truncates it early;
vincent task show prints the same
thing plus the source digest, and is where an audit is actually done.
A structure step gets a tier of its own. A parallel group’s sub-steps share
the group’s index, so the group is one header and each sub-step sits beneath
it. A loop (§7.8) goes one further: its body’s rows are grouped by
iteration, folded shut with the latest one open, and a for_each iteration’s
header names the item it ran on. Ten passes of a four-step body is forty rows,
and the one you arrived to read is almost always the pass it stopped on.
Folded is not unreachable. space opens or closes the tier the cursor is in,
→ opens it and ← closes it, enter on a folded tier’s header opens it
(and on a drawn attempt still opens Output), and O/C open and close every
tier of the task — the same two letters the Diff tab uses. Latest-open is only
where the timeline starts: what you open stays open while the task refreshes,
and opening another task starts fresh. ↑/↓ stop once on a folded tier,
on its header, so the cursor is always somewhere you can see; the Output tab’s
←/→ still walk every attempt, folded or not.
A multi-round fan_out (§7.6) gets the same tier under a different word: its
rounds read round 0, round 1, … — 0-based, because that is the number the
transcript file and the log line use — and the same keys open and close them.
The board’s and the header’s step column say the same thing more briefly: a
task inside a loop reads 3/7 green · loop 4/10 · repair 2/3 — the pass it is
on out of the loop’s real extent (a 3-item for_each reads loop 2/3, not the
ceiling it is bounded by), and the body step that pass is on, which is the one
thing the outer 3/7 cannot say because it counts the whole loop as one step.
Two things on an attempt line are worth telling apart. A red word like
check_failed is vincent’s failure reason — a fixed set of constants, and
vincent’s own verdict. A cyan » 3 tests red in internal/store is the step’s
own status message, free text it set while it was running and the last thing
it said before it ended. It is never a cause: a step killed on a timeout may be
carrying a line it wrote half an hour earlier.
An attempt that did not succeed also gets a dim line beneath it with its result summary — the agent’s final message, or the tail of a command’s output. It is the sentence that decides whether to open the transcript.
| Key | Does |
|---|---|
tab / shift+tab |
Next / previous task tab |
] / [ |
Next / previous task tab |
1–5 |
Steps & Attempts / Task Details / Output / Diff / Workflow |
6 |
Pull Request — only when this task has a linked pull request and GitHub is on; tab/shift+tab skip it otherwise |
enter |
From Steps & Attempts, open the selected attempt in Output — or open the folded iteration/round tier the cursor is on |
space |
On Steps & Attempts, open or close the iteration/round tier the cursor is in |
←/→ |
On Steps & Attempts, close / open that tier |
O / C |
On Steps & Attempts, open / close every iteration and round tier of this task |
←/→ or h/l |
On Output, select which attempt’s output to show |
f or G |
Follow the live output again |
v |
More or less detail: quiet → compact → normal → verbose (tool lines, then reasoning, the run’s own metadata, then unrecognized lines) |
ctrl+o |
Show the assistant’s original Markdown instead of the rendered view |
ctrl+y |
Copy an assistant message, its plain text, or one of its code blocks |
e |
Open this attempt’s whole transcript in $EDITOR |
↑/↓ |
Select an attempt or Task Details section, scroll Output, or move between diff files — according to the active tab |
pgup/pgdn |
Scroll the selected Task Details section |
How the pane reads
Every record is a two-column gutter plus its content. What the agent said
is unmarked and sits flush left; what it did is glyphed — · reasoning, ▸ a
tool call with its outcome indented under it, # the run header, ☰ the plan.
A monochrome terminal or an SSH session loses nothing to that scheme, which
colour alone would not survive. There are no timestamps: on an 80-column pane
they would spend nine columns of every line answering a question the timeline
already answers per attempt.
A message is a message, however it arrived. An agent sometimes delivers one answer as several records. Consecutive assistant records are read as one Markdown document, so a table, a list or a fenced block spread across them is the one thing it was written as, and its link references are numbered once for the whole message. Anything that is not assistant prose — reasoning, a tool call, command output — ends the message, and what comes after it starts the next.
What the agent said is rendered as Markdown. Headings, emphasis, strong
text, ordered and unordered lists, nested lists, blockquotes, inline code,
fenced code and horizontal rules become terminal structure instead of literal
syntax. That structure lives inside the assistant column — the gutter is
untouched — and every marker is a glyph, not a colour, so a heading’s ▌, a
quote’s │, a list’s •/◦/▪, a code block’s ▏ and an inline span’s
backticks all survive with the styling stripped. A code block keeps its
indentation and its hard line breaks; a line too long for the pane continues on
the next one at the block’s rail rather than being cut off. It also shows the
language the fence declared, as a dim word at the rail, and tints its body — the
tint is styling and nothing else, so stripping the escape sequences gives back
exactly the code the agent wrote, and a language vincent does not know renders
plain.
Tables and links, too. A table needs its delimiter row to be a table, so
prose containing a | stays prose. When it fits, it is drawn aligned with a
rule under the header and no borders; when the pane is too narrow for its
columns’ widest unbreakable words, each row becomes a stacked
column: value record opened by ▪ — never a clipped grid, and never a
sideways scroll. A link renders its label as ordinary text with a dim [1],
and the message ends with the [1] https://… lines that resolve them, one per
distinct destination. An image is its alt text plus its source in that same
list. Nothing here is fetched, opened, or turned into a terminal hyperlink:
vincent emits no OSC 8, so a destination is text you can read and copy.
Constructs outside that list — reference links, autolinks, bare URLs, titled links, HTML, footnotes — render as the characters the agent sent, and raw HTML is never parsed, fetched or executed.
Only the agent’s prose is interpreted. Reasoning, tool calls, tool results,
command output, errors and unmodeled lines stay literal behind their own gutter
marks: a # in a grep hit is a #, not a heading. The rendering is derived
too — the transcript on disk and the API keep the agent’s exact bytes, and
resizing the terminal re-renders from those rather than reflowing what was
already drawn.
The chat workspace’s conversation body is this same pane, at the same level.
Scrolling away from the tail keeps your place. A pane that is not following
holds onto the block at the top of it, so resizing the terminal, changing the
level with v or ctrl+r, toggling ctrl+o, and output old enough to be
dropped from the window all leave you looking at the same thing. Press f
(ctrl+g in a chat) to go back to following the tail.
Seeing the source, and taking it away
ctrl+o swaps the rendered view for the stored Markdown, exactly as the
agent wrote it — # back on the headings, backticks back on the fences. It is
the escape hatch for a render that surprised you, and it is a display state and
nothing more: the records, the live tail, the level and the transcript on disk
are untouched. Like v’s level, it is one choice for the whole session and
shared with the chat workspace — set it in either place and both follow — and it
is gone when you quit. The pane’s title says raw while it is on.
ctrl+y opens a copy picker: a searchable list of what can be taken out of
the assistant prose on screen, newest message first.
| Row | Puts on the clipboard |
|---|---|
markdown |
The message as the agent wrote it |
plain text |
The same structure with the punctuation gone — headings as their own line, • list markers, quotes prefixed, fences dropped |
code block |
One fenced block’s contents, with no fence and no language label, whitespace and tabs kept |
Payloads come from the source, not from what is drawn, so the pane’s width never ends up baked into what you paste, and nothing that arrives after you open the picker moves the row you are pointing at. A row names a message rather than a position in it, so picking one that is still being written copies the whole message as it stands at that moment. Escape sequences and control characters are stripped on the way out for the same reason they are stripped on the way in: a clipboard gets pasted into a terminal.
vincent tries your system clipboard first and, if that refuses, hands the text to your terminal over OSC 52 — which is the one that works over SSH. The notice says which happened, and never claims a copy it could not verify.
Copying a link’s destination is not here yet: links still render as the characters the agent sent, so there is no destination for the picker to offer.
What v adds
quiet is what the agent said, and whatever went wrong. Below compact it
drops the ▸ tool calls and the outcomes under them, the ✓ done · … line of a
run that succeeded, and the lines vincent’s parsers do not model — not even
their count, because a count is an offer to expand and this is the level that
makes no offers. It is the level for reading an answer rather than watching a
run: a two-sentence reply arrives as two sentences instead of buried in file
reads and greps.
A failure is never hidden by a level. An ✗ result, an error line and a turn’s
own fail reason all render at quiet, and so does the result text of an
attempt that printed nothing else — a codex turn with no assistant message —
which would otherwise leave a turn header with nothing under it. A command
step’s pane is identical at quiet and compact: quiet is a rule about what
the agent narrated, and a command step narrates nothing.
compact is what the agent said and did, and nothing else. Reasoning is
hidden, and so is everything about the run itself.
normal adds reasoning, truncated to its first lines — and, for an agent whose
CLI reports them, two records that answer questions the pane could not answer at
all before. A # line at the top of the run names the directory the agent said
it was working in and the tools it was given, which is the only place “what
could this agent actually reach” is written down. And the closing ✓ line
carries the run’s own account of itself: how long it took, how many turns it
burned, how many tool calls a permission rule refused, and — when it is not the
ordinary one — why it stopped. stop: max_tokens on that line is the difference
between a model that finished and a model that ran out, which both read as a
bare success before.
normal also carries the agent’s running to-do list, on a ☰ line, for a
CLI that reports one. Every version of the list arrives whole rather than as a
change to the last one, so the line shows where the agent is — done entries
✓ and dimmed, pending ones ○ — and a reader who opens the pane mid-run does
not have to reconstruct it. It sits at normal for the reason the run header
does: a plan is what the agent intends, which is neither what it said nor what
it did.
verbose adds the API-time split, the cache read/write token split, a per-model
breakdown for a run that used more than one, and the lines vincent’s parsers do
not model, expanded out from behind their count.
verbose is also the only level that shows what a command printed — the
output body itself, flush left and dim, the way a command step’s own output
renders, because it is the same thing rather than vincent’s account of one. It
is held back below verbose deliberately: a step running go test ./... would
otherwise flood the level most readers use. A body long enough to hit the cap
ends in … output truncated, because a cut a reader cannot see is
indistinguishable from a command that printed exactly that much.
A tool call that a permission rule refused is marked ⊘ rather than ✗, at
every level. The distinction is worth a glyph: ✗ is the agent’s problem, and
⊘ is the step’s permission mode.
Only Claude Code reports the run header and the run metadata today. On codex and cursor those lines simply do not appear — vincent does not synthesise one from what it happens to know. The plan and the command output run the other way: only Codex reports those, and on claude and cursor they are absent for the same reason rather than invented.
Reading the whole transcript
The output pane holds the end of a transcript — the last 256 KB, capped at 5000 records — because a single attempt is allowed to produce gigabytes. When a step fails, the part you want is often the beginning, which is exactly the part not on screen. When it has dropped something, the first line in the pane says so: … earlier output truncated — press e for the whole transcript.
e hands the complete file to your $EDITOR, the same way e opens a
workflow file in the workflows view. What opens is the raw JSONL on disk —
the lossless record, including lines vincent’s parsers do not recognize —
rather than the pane’s rendering. It is the same file
vincent task show <id> prints the path of.
Two cases answer instead of opening: a step that never wrote a transcript (a manual gate), and a transcript that retention has already pruned. Neither opens an empty buffer, because an empty buffer reads as “the step produced nothing”.
Follow mode belongs to the live attempt. It is unavailable on a finished one, and a step advance moves your selection only if the cursor was already on the live attempt — so reading an old step is never interrupted by a new one starting.
The Diff tab
The Diff tab is git diff against the merge-base with the commit the task
was cut from, including uncommitted changes, syntax-highlighted. It is fetched
when you activate the tab and on an explicit refresh, never on every output
chunk.
It is grouped by file, and every file starts collapsed — so the first thing you see is what the task touched, not the first eighty lines of whichever file git wrote first:

| Key | Does |
|---|---|
↑/↓ |
Move between files (the pane scrolls to keep the cursor in view) |
enter or space |
Expand or collapse the file under the cursor (→/← too) |
O |
Expand every file |
C |
Collapse every file — which is how the tab opens |
pgup/pgdn, f/b, u |
Scroll by lines inside what is expanded |
[ |
Back to the Output tab (] advances to Workflow) |
Clicking a file’s row folds it; clicking a line of code selects its file and leaves it open. The mouse wheel scrolls whichever tab is on screen, unless a popup is open — a popup takes the mouse as well as the keyboard, so a tick behind it moves nothing.
The counts beside each path are the added and removed lines inside that file’s
hunks, and the line above the list totals them. A binary file says so
instead of showing +0 -0, and a rename reads old → new.
Folds are remembered per file path, so leaving and re-entering the tab — a refresh — keeps what you had open, even if the agent has since touched other files. Moving to another task starts collapsed again, and nothing is written to disk: a fold is how you are reading one diff, not a setting.
On a task that fanned out, the tab grows an outer level: lane › file, one
row per lane in the order the parent merged them, and a final the task’s own
commits holding everything that belongs to no lane. After the join the
parent’s diff is otherwise one wall of merged hunks with nothing saying who
wrote what. A lane that merged cleanly and changed nothing says so rather than
reading +0 -0, l opens the lane whose section the cursor is in, and a task
that fanned nothing out is the flat file list it has always been — including its
fold state, which is still keyed by path alone.
Walking a fan-out
A fan_out step runs its lanes as real child tasks, each
with its own worktree, branch, steps and transcript. None of that lives in the
parent, so the parent’s job is to be the way in.
Lanes are kept off the board’s list on purpose — a sixty-four-lane tree would
bury the work you actually asked for — so L on a fan-out parent hangs them
underneath it as indented rows, and L again folds them away. Nested fan-outs
compose, down to fan_out.max_depth. An expanded lane row is an ordinary task
row: the same folding, the same space selection, the same action keys. Lanes
stay out of the flat count, out of every group header’s count and out of the !
attention badge, so a board with nothing expanded reads exactly as it did
before. What is expanded is remembered for the session and not written to disk —
a task id is not a label, and there is no honest way to restore one archived
while the TUI was down.
Inside the workspace:
| Key | Does |
|---|---|
l |
Open the selected lane’s workspace |
U |
Open this lane’s parent task |
< / > |
Step the Output pane’s lane selector |
esc |
Back to the task you came from, one at a time |
l and U work in every state the parent is in — a parent blocked on a
lane or parked done is exactly when a lane is worth reading. Which lane l
means depends on where you are standing: the Workflow tab’s graph cursor, the
Output pane’s selector and the Diff tab’s lane sections are each taken at their
word, the Steps timeline means the fan_out row under the cursor, and every
other tab means the lane the failure is about. U is its reciprocal — parent
task in Task Details is a jump, not a number to memorize. Where a tab
already gave l the vim meaning of →, that is kept for a task with no lane to
open.
esc pops one task, so three lanes deep is three presses back rather than
one jump to the board. A task on that trail that has since been archived is
dropped from it rather than opened.
The Output pane’s </> cycle the parent’s own output and each lane’s, so
you can watch a running lane without leaving the parent. Exactly one extra live
stream is open at a time — interleaving sixty-four would be a lossy render of
something that looks like a bug, and the transcript file is still the durable
copy.
When the join fails, the workspace says which lane. A parent blocked on
lane_failed, merge_conflict, fan_out_invalid or fan_out_limit carries
the lane id, its child task, the engine’s own sentence — the conflicted paths,
the offending line or bound — and the lane’s own block reason, on the detail
header and on the fan_out step row, with l to go there. Only the attempt the
task is parked on is annotated; an earlier retried one is history.
The Pull Request tab grows one row per lane beneath the parent’s own
section, with the lane’s branch and any linked pull request. Lane rows carry no
checks — checks stay one call for one task, and l opens the lane, whose own
tab has them.
The action bar
Below the panes, the action bar shows exactly the actions valid in the current state — the daemon computes that list, the TUI renders it. With tasks selected on the board it acts on all of them; see Acting on several tasks at once.
| Key | Action | Valid from |
|---|---|---|
a |
Approve the gate | awaiting_gate |
x |
Reject the gate | awaiting_gate |
r |
Retry the blocked step | blocked |
R |
Repair with an agent — a one-off run in this task’s worktree | blocked |
E |
Edit the step’s prompt or command in $EDITOR, then retry |
blocked |
s |
Skip the current step | blocked, awaiting_gate |
p |
Pause / resume | queued, running / paused |
c |
Cancel the task (asks first — a running step is killed) | most states |
A |
Archive (asks first — the worktree is removed) | done, aborted |
F |
Follow up — run more work in this finished task’s worktree | done, aborted |
E opens the failing step’s prompt or command in your editor, and the override
applies to this task’s snapshot only — the workflow file is untouched.
R and F open a form instead of acting straight away; they are the two task
actions that need something written, which is also why neither is offered for a
bulk selection.
What each action means in full is in Task lifecycle.
Repairing a blocked task
r, E and s all leave the worktree exactly as the failed step left it —
they re-run a step, rewrite its text, or walk past it. When what is wrong is a
file, press R on a blocked task and a one-off agent goes and fixes it, in
this task’s worktree, on this task’s branch.
| Key | Does |
|---|---|
↑ / ↓ |
Move between the prompt and the agent / model / effort rows |
enter |
Open the row under the cursor — the prompt field, or that row’s picker |
e |
Write the prompt in $EDITOR instead |
ctrl+s |
Start the repair |
ctrl+t |
Switch between the form and this task’s details, without leaving the popup |
esc |
Close without repairing — the draft is discarded |
The prompt is the only required row, and it is prose: write what you want done, not a template. The daemon puts the context around it — the task, the blocked step’s rendered prompt or command, the failure reason and exit codes, the last 200 lines of the failed attempt’s transcript and the path to the whole file, so the agent can read further itself.
Inside the prompt field enter is a newline (a repair prompt usually wants
more than one line), ctrl+s keeps the text, and esc discards it. Agent,
model and effort are optional; set they apply to this run only and win over the
task’s overrides and the workflow’s defaults.
When the repair agent finishes the task returns to blocked — same step, same
reason — whatever it exited with. That is the point: you look at the diff and
then decide whether to r. The repair appears in the timeline as its own
entry under the blocked step, labelled repair (ad-hoc agent), with its own
transcript, tokens and cost; it is not an attempt of that step and does not use
up its retries.
Following up on a finished task
A done or aborted task still owns everything it made — its worktree, its
branch, its commits — until you archive it. F is how you do one more thing in
there without leaving vincent: rebase the branch onto a main that moved, add
the commit a reviewer asked for, drop the stray file the agent left.
| Key | Does |
|---|---|
↑ / ↓ |
Move between the run form, what to run, and the agent / model / effort rows |
enter |
Open the row under the cursor — the run-form list, the text field, or that row’s picker |
e |
Write the prompt or command in $EDITOR instead |
ctrl+s |
Start the follow-up |
ctrl+t |
Switch between the form and this task’s details, without leaving the popup |
esc |
Close without running anything — the draft is discarded |
The top row picks what kind of run this is, and it decides what the row under it means:
| Run form | The row below it is |
|---|---|
agent |
a prompt — prose, not a template |
command |
a shell command, run under the daemon’s shell (/bin/sh, or pwsh on Windows) |
workflow |
a name picked from the registry, run against this task’s worktree instead of a new one |
Switching between the three keeps what you typed in each, so you can look at the command form and come back to your prompt.
When the run finishes, the task returns to the state it came from — done to
done, aborted to aborted — whatever it exited with. A follow-up never
changes a task’s verdict; if a successful one could promote an aborted task to
done, any command that exits 0 could undo an abort you made on purpose.
Follow-ups are repeatable, and each one is a round. The timeline heads them
↳ follow-up 1, ↳ follow-up 2 under the workflow’s own steps, with each step
of the round named beneath — they are not steps of the workflow, and the
workflow’s k/n does not move.
If a follow-up step fails the task blocks at that round, and the usual keys
mean the usual things there: r re-runs the follow-up where it stopped, R
repairs against that failure, s abandons the follow-up and puts the task
back where it came from, c aborts. E is refused — edit-and-retry rewrites a
step in the task’s snapshot, and a follow-up is deliberately not in it.
For more than one task at a time, use the command line:
vincent task follow-up <id> --run 'git rebase origin/main'
(CLI reference).
Answering a question
When a claude step asks something mid-run, the task enters awaiting_input and
gets a badge on its row plus a footer hint. Press enter on the row to open the
answer form.
| Key | Does |
|---|---|
space |
Pick an option (toggles, for a multi-select question) |
e |
Type your own answer — options are suggestions, never a list |
enter |
Submit; the run resumes in the same session where it stopped |
ctrl+t |
Switch between the question and this task’s details, without leaving the popup |
esc |
Close without answering (what you picked is kept) |
While e has a field open, enter keeps what you typed and esc discards it —
the submit is the next enter, on the form itself. The field opens under the
question it answers and wraps as you type, so a long answer stays readable
before you commit it; the committed answer is shown back on its row, wrapped
the same way.
The form is a popup, and it never steals focus: auto-opening under a keystroke is how an answer gets lost. It announces itself and waits for you.
Reading the task without leaving the popup
All three popups — the answer form, the repair form and the follow-up form — have a two-tab strip of their own along the top: the form itself, named Question, Repair or Follow-up, and Task details.
ctrl+t switches between them and the popup stays open. Task details is
the same inspector the workspace’s Task Details tab shows, with the same
sidebar and the same ↑/↓ and pgup/pgdn keys: the original prompt, the
project, the workflow and the step that is asking, the agent, model and effort,
the timings and cost, and the linked GitHub issue or pull request.
Nothing about your draft changes while you read. Options you picked, an answer
you typed, a half-written repair or follow-up prompt and the agent/model/effort
you chose are all exactly where you left them when you press ctrl+t again.
That matters most on the repair and follow-up forms, where esc throws the
draft away — before this, looking something up meant retyping the prompt.
ctrl+t works while a text field or a picker is open, and types nothing into
it. On the Task details tab the pane is strictly read-only: no task action
fires from it, and it offers neither o nor P. esc there goes back to the
form rather than closing the popup — one layer per press — so closing a popup
from the details tab takes two.
The takeover screens
Reached from the command palette (:), except new task which keeps a direct
key.
New task — n
Opens for the project you are looking at. A guided form: project → workflow (with its description and step list, flagging steps whose agent is unavailable) → (GitHub issue) → title → description → fields → base branch → priority → optional agent/model/effort override.
When the selected workflow declares fields:,
the Fields row is pre-rendered in declaration order. It shows labels,
descriptions, type/required badges, and regex help; boolean values toggle between
true and false. An enum row
opens a scrollable, filterable list of its declared values on enter — esc
cancels, enter commits, and a multiple field toggles membership with the
list open — while ←/→ step a single-choice row through the values in place
without opening it, the way a boolean cycles. A multiple row is not stepped:
“the next set” has no meaning, so the list is the only way to change one. An
optional single-choice row also stops at (unset), which is the only way back
to empty for a row the workflow owns and that therefore cannot be deleted. A
declared default: seeds the row when the workflow is selected. Workflow-owned names are locked, but their values remain
editable. You can still add and delete custom key/value rows — additional,
undeclared fields remain valid and are recorded on the task. Values are kept
when you switch workflows, including fields that the new workflow does not
declare.
The GitHub issue row appears only when this project’s issues can be read:
the github integration is on, the
project’s origin remote is a github.com repository, and vincent has a
credential — gh logged in, or GITHUB_TOKEN/GH_TOKEN in the environment the
daemon inherited. Otherwise the row is simply not there, and no GitHub call is
made. vincent doctor says which of those is missing.
Its picker lists the repository’s open issues, newest first, and narrows as you
type, like every other picker here. Choosing one fills the title with #N and
the issue title, fills the description with the issue body plus a trailing
GitHub issue #N: <url> line, and fills any of the workflow’s declared issue,
labels, assignee or milestone fields whose declared type accepts the value
— issue being the issue number, the one a run: body can read. All of it lands in the ordinary
editable rows — rewrite or clear anything before creating, and what you leave
is what the task gets. A (none) row at the top of the picker removes the link.
The issue is read once, when you create the task, and stored on it. Editing
the issue on GitHub afterwards does not change what a later step sees; the
snapshot is what .Issue
renders from.
The same row shows a pull request when you arrived here with c from the
pull-requests screen — the number, the title, the head branch,
and, for a fork, that nothing can be pushed back to it. A pull request is never
picked from inside the form: a task runs on the pull request’s head branch, so
that is a decision made where the pull request is on screen. The prefill lands in
the same editable rows — the title, the description, and a declared pull field
carrying the number. An issue and a pull request are mutually exclusive on the
create call: they would prefill the same title and description from two sources,
and the daemon refuses a request naming both.
On a wide terminal those fields are grouped into six stages in the left rail: Project, Workflow, Task details, Git & priority, Execution, and Review. The main pane shows only the fields in the current stage, while Review gathers the complete request beside the Create action. The rail follows the ordinary field cursor — there is no separate Next button or second set of navigation keys.

| Key | Does |
|---|---|
enter |
Open the focused field’s editor or picker |
a / d |
In Fields, add or delete a custom row (declared rows cannot be deleted) |
e |
Edit the description in $EDITOR |
+ / - |
Nudge the priority (higher runs first) |
R |
Re-probe the adapters (the list is otherwise cache-served) |
ctrl+s |
Create the task |
The agent row warns when the adapter the task would run on is out of quota —
· usage limit until 14:20, from the same quota the board header badges.
It warns and nothing else: the form submits, and the task waits its turn on
the ordinary usage_limit hold if
the window is still shut when it is admitted.
The override pickers are fed by live adapter data, tagged with where each option came from, and always accept free text. They are windowed and filterable — you type to narrow, which is what makes cursor’s ~180-model catalog usable. Each resolved field shows which level won (step, task, workflow, adapter), so the form tells you what will actually run rather than what you typed.
Projects
On a wide terminal the repository list stays in the left rail. The selected
project’s path, branch convention, workflow and concurrency defaults, and
current tasks fill the main pane; a or enter puts the existing add/edit form
in that same pane. This keeps the project you were looking at visible while you
change its configuration.

| Key | Does |
|---|---|
a |
Register a repository |
enter or e |
Edit the selected project |
d |
Remove it (asks first; its task rows go with it) |
/ |
Filter by name or path |
ctrl+s |
Save, in the form |
Pull requests
Every pull request across every registered project whose origin is a
github.com repository vincent can authenticate to, grouped by project. The
listing starts open-only and s cycles it through closed and all. Each row
carries the number, its state (open, draft, closed or merged), the
title, the head branch, and the task that claims it — with auto when the
daemon’s reconciler matched it by head branch and human when somebody linked
it by hand.
The entry appears in the palette only when at least one project qualifies; with none, the screen is unreachable rather than empty. A project whose listing fails shows its reason on that group and does not hide the others. A reconciler tick that links or unlinks a pull request re-renders the screen with no keypress.
| Key | Does |
|---|---|
enter |
Open the workspace of the task that claims this pull request |
o |
Open the selected pull request in a browser |
c |
Create a task from this pull request — it runs on the pull request’s head branch, and the form is editable first |
l |
Link it to a task in the same project |
P |
Open a pull request for a task that has none — pick the task, then push its branch and create it |
u |
Unlink it (asks first) |
s |
Cycle the listing between open, closed and all |
R |
Re-list every project |
↑/↓ |
Move the selection |
/ |
Filter by number, title, branch or project |
P is the one key here that is not about the selected row. This screen has no
task rows — its question is “what is open across everything I run”, and a task
with no pull request is not an open pull request — so P offers a picker of
every task with a branch and no pull request, and choosing one opens that task’s
workspace with the form already up. Eligibility is exactly that: a branch, and
no pull request. Anything else is reported by the push or the create failing
with a named reason rather than guessed at in advance.
u is a sticky refusal, not a reset: the daemon records that a human
removed this link, and the reconciler will not re-apply it on its next tick.
The confirmation says so.
c opens the New task form seeded with the row — the screen makes
no GitHub call of its own and computes no prefill; it hands the form a project
and a number, and the daemon fills in the rest. It is refused on a row a task
already claims, saying which task, because two tasks cannot hold one branch, and
on a row that names no head branch, because then there is nothing to run on.
s is why closed and merged rows are reachable at all: the listing defaults to
open, which is the question this screen usually asks, and pulling a repository’s
whole pull-request history to answer it would be paid for by everyone. Acting on
a merged pull request and redoing a reverted one are the cases the other two
states exist for.
Workflows
The merged registry with scope badges and validation status.
On a wide terminal the merged registry stays in the left rail. The focused
pane names the selected entry’s scope and source, availability and findings,
then reveals its resolved step list with enter. g uses that focused pane
for the graph while keeping the registry visible, so esc returns to the same
entry with its surrounding scopes still in view.
| Key | Does |
|---|---|
enter |
Show the entry’s steps |
g |
Draw the entry as a control-flow graph |
i |
Edit the entry in a structured form |
a |
Create a workflow in a chosen scope |
f |
Fork the entry into another scope, where it shadows the original |
e |
Open the file in $EDITOR — the view updates when you save |
R |
Re-read the registry |
Authoring — i, a, f
The view authors the registry as well as reading it. i opens a structured
form on the entry under the cursor: rows, not YAML.

Every row comes from the schema the daemon serves, so a field that is not legal
on the step you are editing is one the form does not offer — an agent step has
no run: row, and the type row inside a parallel group lists only what may
go there. Every field the schema publishes has a row: a step’s timeout:,
max_retries:, retry_backoff:, env:, max_parallel:, count:,
for_each:, max_iterations: and schedule: are read from the file and shown
as it wrote them, and max_retries: 0 reads as 0 rather than as (unset) —
those two mean different things.
The rows that hold a nested body are entered with enter and left with esc,
one layer per press: a step’s steps: and a fan-out’s lanes:, lane: and
merge:, and the workflow’s own fields: and defaults: — the declared fields
your new-task form will ask for, and the agent, model, effort, permission mode,
retry and timeout every step inherits, including a container: block.
a creates a workflow: choose a scope (global, or one of your projects) and a
file name, and the editor opens on what was written. f forks the entry under
the cursor — including a built-in, which is the only way to change one. A
fork keeps the source’s own name:, which is what makes the copy shadow the
original; pick a project scope and the project’s copy wins from then on.
There is no delete of a workflow: removing one means removing its file. A
step, a lane or a declared field inside a workflow can be removed, and d asks
before it does.
Saving preserves everything you did not edit — comments, key order, blank lines. The daemon owns the file and applies your change to its bytes, so the notes you left in it survive.
If two things write the same file — an agent running create-workflow, or your
own $EDITOR — the second save is refused rather than silently overwriting the
first. The form says the file changed on disk and R re-reads it.
Inside the form:
| Key | Does |
|---|---|
↑ / ↓ |
Move between rows |
enter |
Edit the row, cycle its values, or descend into a nested body |
a |
Add a step, lane or declared field after the one under the cursor |
d |
Remove the step, lane or declared field under the cursor — asks first |
K / J |
Move it up or down. Capitals: k and j still move the cursor |
ctrl+s |
Save, in the multi-line pane and the key/value sub-form below — the two places enter does something else |
R |
Re-read the file — the reload a refused save offers |
esc |
Leave the nested body, then the editor |
There is no save key for a row, because there is nothing to save: committing a
row with enter is the write, and so is answering a, d or K/J. Each
one becomes a single edit operation carrying the version the last read handed
back, and a value the daemon refuses stays on screen beside its error rather
than being reverted under you.
a on a list of steps asks which type first, offering only the types that are
legal where the cursor is, and writes a skeleton with that type’s required
fields filled in with placeholders — so the file is valid between edits and the
next change is not refused for a step the form itself just wrote.
Some rows open something bigger than a text field:
| Row | What enter opens |
|---|---|
prompt:, run:, instructions: |
A full-pane multi-line editor. enter inserts a newline, ctrl+s saves it back as a \| block scalar, esc abandons it. Opening one and closing it again writes nothing |
agent:, model:, effort: |
The same filterable picker the new-task form uses, listing the agents the daemon detected and the selected agent’s own catalog |
env:, a lane’s fields: |
A key/value sub-form: enter edits or adds a key, d drops one, ctrl+s writes the mapping |
| An enum, a boolean | Nothing — enter cycles it in place |
A whole-number row and a duration row are checked before the write, so
timeout: 2 minutes is refused on the row that typed it rather than after a
round trip. The daemon stays the authority for everything else, and for these
too.
And in the prompt a and f open:
| Key | Does |
|---|---|
tab |
Move between the scope and the file name |
← / → |
Choose the scope |
enter |
Write the file and open the editor on it |
esc |
Close the prompt |
For a fork the name row is the file name, not the workflow’s — the name:
inside stays the source’s, which is the point.
e still means $EDITOR, here and everywhere else. It is the escape hatch for
a file broken badly enough that the forms cannot load it.
The graph — g
A numbered list of top-level steps can name a parallel group or a fan_out
but cannot show where control goes. g draws it:

The graph opens over the list, not instead of it: enter’s step list, with
its findings and platform notes, is still there when you press esc.
How to read it. Every one of these works with color turned off:
| You see | It means |
|---|---|
| A box’s second line | The step’s type, and any badges |
if |
The step is guarded — the expression is in the strip at the bottom |
chk |
The step carries a check: |
×3, for_each |
A loop’s driver. max N is an explicit bound |
agent on a merge |
on_conflict: agent — an agent may resolve a conflict |
| A light frame | A parallel group |
| A heavy frame | A fan_out, with its lanes captioned by id and guard |
eager on a fan_out |
schedule: eager — a lane’s dependents start before its siblings finish. barrier is the default and is not badged |
derived from … on a heavy frame |
The lane list was derived at run time from a for_each:, not written out by hand |
templated from … on a heavy frame |
The workflow declares one lane lane: shape, not a list: the single column stands for the lanes for_each: will render at run time, and at most N is its max_lanes: ceiling |
w1, w2 on a lane caption |
Which wave the lane runs in. Lanes stack below the ones they needs: |
| A double frame | A loop body, with a back-edge to its header |
true / false |
A condition’s or break’s two ways out |
END |
Where the workflow finishes |
A fan_out has a merge node below its frame, because the join is a git
merge that runs and can block. A parallel group has none: its join is only its
members finishing. A guard on an ordinary step draws no second branch — false
there means skip and carry on, so the flow is unchanged. A lane naming another
workflow is one collapsed box; drawing that workflow’s own graph in its place is
not in this version.
| Key | Does |
|---|---|
↑ ↓ ← → or hjkl |
Move the selection — the view follows it |
shift + arrows |
Pan the canvas |
pgup pgdn u d f b |
Page it |
tab / shift+tab |
Walk the nodes in source order |
enter |
Open the selected node in full |
e |
Open the file in $EDITOR — the graph redraws when you save |
R |
Re-fetch this workflow’s definition |
esc |
Back to the registry |
enter opens the step in full. The strip is a glance; the popup enter
opens is the reading. It shows every field the node carries, wrapped and never
truncated — the prompt, the run: body, env, instructions,
permission_mode, the input and check timeouts, a group’s max_parallel, a
loop’s count/for_each and max_iterations — above a header naming the
workflow it sits in. A value the step leaves empty that the file’s defaults
block supplies is shown as the effective value and marked (inherited from
defaults), so the graph can answer “what will actually run here” without
folding the two together. A field neither the step nor defaults sets is
simply absent.
Every node opens something. A merge shows its conflict policy and, when it has
one, the resolver agent in full; a lane’s collapsed workflow reference names
the workflow and says it becomes a child task, while an include says its
steps are spliced into this one; a parallel, fan_out or loop header shows
its bounds; END says the workflow ends there.
While it is open the popup has the keyboard: ↑/↓ and the pager keys scroll
it, e and R still work, and esc closes it back to the graph with the same
node selected. A second esc closes the graph, as it always did.

Editing is the point of e here: save the file and the graph redraws in place,
with your selected node still selected. A terminal too narrow to draw a node
says so rather than showing you a flattened shape that is not the workflow; a
graph bigger than the terminal is panned, never reflowed.
A workflow that does not parse has no graph — g says so, and the errors are
already under enter.
The same picture for a running task — the Workflow tab
The workflows screen shows what a workflow is. The task workspace’s fifth
tab — 5, or tab round to it — shows what one task is doing with it.
A list of steps can name a fan_out; it cannot show that two lanes are running
side by side, which branch of a condition was taken, or that a task is on the
second pass of a loop. That is the gap this tab closes, and it bites hardest on
a task that has been parked for hours: the board says it is blocked, and this
says where.
It draws this task’s own workflow, not the registry’s copy — includes
already spliced flat, and any edit + retry rewrite reflected. If someone edits
the workflow file while the task runs, this tab keeps showing what actually ran.
The overlay reads with color off, like the rest of the picture:
| You see | It means |
|---|---|
▶ running |
The step is running now |
✔ succeeded, ✖ failed |
How its newest attempt ended |
⊘ skipped if |
A false if: guard skipped it |
⊘ skipped |
You skipped it by hand |
| Nothing on the node | The task never reached that step |
blocked, awaiting_input, paused |
Where the task is parked, with the reason |
it 2, try 3 |
Which loop pass, and which attempt |
api #42 running on a lane caption |
That fan-out lane’s child task and its state |
A frame below END |
Attempts that ran outside the workflow — a follow-up round, a repair |
A loop still draws once, with its back-edge: nothing unrolls as it runs, so the picture never moves under you while you are reading it.
| Key | Does |
|---|---|
↑ ↓ ← → or hjkl |
Move the selection — the view follows it |
shift + arrows |
Pan the canvas |
pgup pgdn |
Page it |
l |
Open the workspace of the fan-out lane the cursor is in |
esc |
Back to the task you came from, or the board |
tab here is the workspace’s tab cycle, not the graph’s node walk — the arrows
select nodes. There is no e or R: a snapshot has no file to open and no
registry entry to re-read.
Lanes are drawn as the engine schedules them. A lane with needs: sits below
the lanes it needs with an edge from each, one row per round, so the picture has
the waves the run has; a lane that needs nothing hangs off the step’s own header
and a fan-out whose lanes need nothing is one wave, laid out exactly as before.
A lane’s caption carries the child task, its state and — the one fact the parent
cannot otherwise tell you — that lane’s own block reason, because a lane’s
steps run in the child and never appear on this graph.
Chats
A second board, for chats — conversations with an agent, each in its own worktree. Chats are not tasks and never appear on the task board, so they get a board of their own: one row per conversation, with its id, state, agent, last activity and title, grouped by project.
Grouping is by project only: tui.board.group_by’s workflow levels mean nothing
for a chat, which runs no workflow, so g is not offered here. Folds persist in
{data_dir}/tui.json separately from the task board’s, so folding a project
here does not fold it there.
A chat waiting on you is sorted to the top and counted in this header’s badge —
and nowhere else. ! and the task board’s needs-attention count stay
task-only.
| Key | Does |
|---|---|
enter |
Open the chat’s workspace |
n |
Start a chat in the project you are looking at |
a |
Archive the chat — asks first, and re-offers with the force when the worktree is dirty |
/ |
Filter by title, agent or branch |
← / → |
Collapse or expand a project group |
s |
Cycle the listing between live, archived and handed-off, and all |
r |
Reload the board |
Archived and handed-off chats are off this board by default, the way
archived tasks are off the task board. s cycles the listing — live, then the
terminal ones, then both — and the header names the listing whenever it is not
the default, so an empty board is never mistaken for no chats. A terminal chat’s
last-activity cell shows when the chat ended rather than a duration that keeps
counting; and a on such a row declines with a note instead of asking to remove
a worktree that is already gone, or that a handoff gave to a task.
n is the one key whose meaning depends on where you are: on this board it
starts a chat, everywhere else it opens the new-task form. The create form takes
project, title, agent, model, effort and base branch; ctrl+s creates and drops
you straight into the workspace. With no project registered, n says so on the
board instead of opening a form you could not submit — add a repository in the
Projects view (4) first.
Four of the six rows are lists — project, agent, model and effort — and they are
the same list the new-task and follow-up forms use: enter opens one, /
filters it as you type, ↑/↓ walk it and enter picks. The model and effort
lists are the selected agent’s own catalog, tagged cli where the CLI itself
reported the value and curated where it comes from vincent’s built-in list and
may be stale; both lead with an (agent default) row naming what that agent
would use, and both end with a row for typing a value the catalog has never
heard of — a model shipped this morning is not in it. Changing the agent
re-scopes both lists and clears anything chosen under the previous one.
←/→ still step the project and agent rows one at a time without opening
their list, which is quicker when you have two of something. They are not
offered on the model and effort rows, where stepping through a hundred values
answers nothing.
Title and base branch are typed. The base row’s placeholder names the selected project’s actual default branch and follows the project row; leave it empty and the daemon resolves that default at creation.
Only an agent that can resume its own session can hold a chat, so the agent picker offers only those — which today is all three of them. The daemon refuses anything else at creation with that reason rather than a generic failure, and the form still renders it — it is the backstop for the next adapter, not something you are expected to walk into.
The draft owns the keyboard while it is open: every row, and every open list, so
no keystroke leaks out to the global keys. esc closes an open list and leaves
the draft alone; a second press discards the draft and returns you to the board.
| Key | Does, in the create form |
|---|---|
tab / shift+tab |
Next / previous field |
enter |
Open the focused field’s list, or move on from a text field |
← / → |
Step the project and agent fields in place |
ctrl+s |
Create the chat and open it |
esc |
Close an open list, else discard the draft |
Chat workspace
One conversation: the finished turns above, the running turn’s live output below them, and a composer at the bottom.
Your own messages are easy to find in it. A prompt is drawn as a right-aligned
bubble — as wide as it needs to be, up to about two thirds of the pane, wrapping
inside itself and marked with › on every line, not just the first. The
agent’s half of a turn is untouched: flush left, full width. The distinction is
alignment and the marker as much as it is the colour, so it survives a
monochrome terminal, NO_COLOR and an SSH session that lost the palette. Line
breaks you typed are line breaks in the bubble, and a long prompt is not cut
short — the tail of what you asked stays readable.
The composer sits inside a titled message box, so the field you type into does
not read as one more row of the conversation. The border comes out of the pane’s
height rather than being added on top of it: the screen is the same height it
always was, and the hint line is still the last row of it.
| Key | Does |
|---|---|
enter |
Send the message |
ctrl+x |
Stop the running turn — its process tree is killed |
ctrl+r |
How much of the conversation to show: quiet → compact → normal → verbose |
ctrl+t |
Hand the worktree and branch to a new task — the chat ends |
ctrl+o |
Show the assistant’s original Markdown instead of the rendered view |
ctrl+y |
Copy an assistant message, its plain text, or one of its code blocks |
pgup / pgdown |
Scroll the conversation (the mouse wheel scrolls it a line at a time) |
ctrl+g |
Jump to the live end and follow it again |
esc |
Back to the chats board |
ctrl+t opens the new-task form in handoff mode: the project, the base
branch and the branch are the chat’s, shown but marked (from the chat) and
not editable, because they name a worktree that already exists. Fill in the
title, the workflow and whatever else the task needs — the description is where
the conversation’s context goes, since nothing about the chat reaches the
workflow’s prompts by itself — and the form lands you on the created task. The
task adopts the worktree exactly as it stands, uncommitted changes included.
The chat is then terminal: its header carries a permanent link to the task, it
sorts into the board’s done band, and it cannot be sent to, archived or handed
off again. Only an idle chat can be handed off, and a worktree in the middle of
a merge or rebase is refused with the operation named.
The body is the task workspace’s output pane, same records
and same marks: ▸ a tool call, the outcome indented under it, · reasoning,
# the run header, ✓/✗ the result. ctrl+r cycles the same four levels
v cycles there, and it is the same level — set it in either place and the
other is on it too. ctrl+r rather than v because the composer owns every
printable key: a letter would be typed into your draft.
At quiet you get the agent’s prose and anything that went wrong, with the
tool calls, the closing outcome line and the unrecognized-line count all gone.
At compact you get what the agent said and did and nothing else. At normal
reasoning is truncated to its first lines and the run header appears. At
verbose you get everything, including the dialect lines vincent does not
model — which sit behind a … N unrecognized line(s) (ctrl+r) count at
compact and normal, and leave no trace at all at quiet, rather than
filling the screen.
Scrolling away from the end pauses follow; ctrl+g jumps back and re-arms it.
The mouse wheel does this too — a line per tick, from anywhere in the view,
because the conversation is the only thing here that scrolls. Finished turns are
drawn from their transcripts, so raising the level shows more of what already
happened and not only of what happens next, and turns you wheel back to are
fetched as you reach them. A turn whose transcript has aged out of retention
still shows its answer.
When the agent asks something mid-turn, the chat enters awaiting_input and
the same popup a task’s question opens appears here — same options, same
multi-select, same allow/deny for a permission request. Answering it from
vincent chat answer or over the API closes it here too: the popup follows the
chat’s state rather than its own.
A send refused because max_parallel_chats chats are already running says so
and creates nothing. It is a refusal, not a queue: finish or stop another
conversation and send again.
A turn that runs past agent_timeout, or sits unanswered past input_timeout,
fails and returns the chat to idle — the slot comes back rather than being
held by a conversation nobody came back to.
Daemon
Version, uptime, the config in effect, the adapters detected, and a live tail of
the daemon log. The view reports, it does not act — vincent daemon stop owns
stopping the daemon, and a TUI that auto-started one has no business killing it.
The configuration block is the exception, and only it. tab moves ↑/↓
off the log pane and onto the config list, which then shows every key
config.yaml carries rather than the digest; enter opens a typed editor on
the selected key, and applying it writes the file through the daemon. Stopping
the daemon and vincent gc act on the process supervising this TUI, which is
what that sentence is about; a configuration edit changes a file the daemon owns
and already reloads, and which you can edit by hand at any moment anyway.
Each row shows the value in force and, where they differ, the built-in default. The daemon serves the values it has loaded and not where they came from, so a row says “differs from the default” rather than “set in the file”. A refusal renders against the field, with the value that caused it still there to fix, and nothing is written.
Four keys ask before they apply: notify.command, environment.*,
agents.*.path and listen. They decide what the daemon executes or exposes,
and agents run full-auto by default — a stray keystroke
must not change the argv the daemon spawns as you. listen is written to the
file and the running daemon keeps the address it bound until it is restarted;
the editor says so before you apply it.
| Key | Does |
|---|---|
tab |
Move ↑/↓ between the config list and the log pane |
↑ / ↓ |
Select a configuration key, once the list has the arrows |
enter / e |
Open the editor on the selected key |
R |
Re-read the daemon info, the config and the log |
f / G |
Follow the end of the log again |
i |
Make vincent claude’s status line, or remove it — the exact JSON is shown first |
And inside the editor:
| Key | Does |
|---|---|
← / → |
Choose a value, for a key with a fixed vocabulary |
enter |
Apply the change — the daemon validates and writes config.yaml |
y |
Confirm one of the four keys that decide what the daemon executes or exposes |
esc |
Close without saving; on the confirmation it returns to the field |
Everything here is also
vincent config get|set.
Each adapter row carries what vincent knows about its usage window. Where the
adapter reports one, the reading is spelled out window by window —
quota codex app-server · 5h 28% → 13:00 · 7d 53% → 11:00 · read 09:14 —
with the time it was taken, because a percentage with no timestamp invites
reading a stale figure as a live one. Where vincent has only watched a wall go
up, it says usage limit → 14:20 when the CLI stated that reset and
usage limit ≈ 14:20 when vincent estimated it from
usage_limit_recheck_interval.
An adapter with neither trails quota unknown. This is the one view that says
“unknown” out loud; its job is to list every fact about an adapter, including
the ones nobody has.
Two adapters can report: codex answers on request, with nothing to install,
and claude reports through its status line, which is what i offers to set
up. cursor has no usage surface at all, so it is observation-only and
nothing about it changes. i writes statusLine.command in
~/.claude/settings.json — the one file outside its own data dir vincent
touches besides cursor’s model setting — and it never does so without showing
you the exact JSON first. Whatever status line you had is run by vincent and
printed unchanged, pressing i again puts the file back the way it was, and
declining is remembered so the offer does not come back. See
vincent statusline.
The row also trails with what vincent knows about the build itself: untested
and the builds the adapter was judged against, incompatible version for a
build known to break, and no restricted mode here where the adapter cannot
honour permission_mode: restricted on this OS (see
Agent CLIs). None of them refuses anything from here — a
restricted step on an adapter that cannot restrict is refused when the task is
created — and a tested build says nothing at all, because one green word per
adapter is what makes the one warning invisible.
An orphans line appears in the identity block when the daemon has found
directories under its data dir that no task claims, naming the count and
vincent gc. It is a pointer, not a button: for the same reason this view does
not stop the daemon, it does not delete anything either. Nothing shows when the
count is zero.
A database block sits between the config and the adapters: how big the
database is on disk — including the WAL, which the file size alone leaves out —
what each table holds, how many bytes of workflow snapshots the tasks are
carrying, and how far back the event history goes. Vincent keeps database rows
forever, so this is the block that tells you what that has cost so far. Like
everything else here it reports and offers nothing to press; vincent doctor
prints the same figures in pasteable form, and vincent doctor --fix is what
compacts the file. R re-reads it along with the rest of the view.
| Key | Does |
|---|---|
R |
Re-read the daemon info, the config, the database figures and the log |
f or G |
Follow the end of the log again |
↑/↓ |
Scroll the log |
The log tail is read straight from {data_dir}/logs/daemon.log — the one place
the TUI is not a pure API client, because an endpoint cannot serve the log when
the daemon is the thing that died, which is exactly when the log is worth
reading.
The command palette
: opens it — or ctrl+p, which works everywhere : does and while a text
field has the keyboard. In a chat the composer takes every printable key, so
there : types a colon into your draft and ctrl+p is the way in.
Everything reachable in the TUI is in there by name — navigation to every
screen, and every task action the daemon currently offers. Type to filter,
enter to run, esc to close.
The palette exists so the takeover screens do not need memorized number keys. If you cannot remember a binding, : and ? are the two keys worth
knowing.
Every key
? toggles a help overlay listing every binding for the surface you are on.
The overlay, the palette and the footer all render from one registry in the
source, so a key that exists is a key that is documented.
Global bindings — active whenever the focused surface is not capturing text:
| Key | Does |
|---|---|
: |
Command palette |
ctrl+p |
Command palette, also while a text field has the keyboard |
? |
Toggle help |
tab / shift+tab |
Move between task tabs; on the board filter, commit it |
! |
Jump to the next task needing a human |
n |
New task |
M |
Toggle the mouse |
esc |
Close one layer: popup tab → popup → screen → selection → filter — never quits |
q |
Quit the TUI (the daemon keeps running) |
ctrl+c |
Quit |
Mouse, selection and paste
The mouse is on by default: click to select, scroll to scroll. That takes mouse
events away from your terminal, so native text selection needs the mouse
off — press M, or hold shift while dragging, which most terminals treat as
“bypass the application”.
Paste normally arrives as a bracketed paste from your terminal’s own binding
(Cmd+V, Ctrl+Shift+V, middle click) and lands in the focused field with no
key involved. ctrl+v is the fallback for terminals that pass the key through
instead.
When the daemon is unreachable
The TUI does not pretend. It shows the disconnected state in the header, keeps the last data it fetched visible rather than blanking the screen, and reconnects on its own when the daemon comes back — SSE reconnection resumes durable events from where it left off, so nothing is missed.
The daemon view stays useful throughout: its log tail is read from disk, which is the one thing still true to show when the daemon has died.
See also
- Scripting vincent — everything here, without a terminal.
- Task lifecycle — what the actions do.
- Troubleshooting.