Terminal client (P9)
The TUI is opt-in. Line mode (nodwyn, or nodwyn chat) remains the default
and the path used by scripts and promotion selfchecks. The old --plain flag
is accepted and ignored.
nodwyn tui --repo /path/to/project
nodwyn tui --repo /path/to/project --worktree
nodwyn tui --resume SESSION_ID
nodwyn tui --socket /absolute/private/directory/sock --repo /path/to/project
The TUI is a client of the daemon, as the console is (ADR 30, amended
2026-10-17): the daemon owns the sessions, configuration, credentials,
schedules and every refusal, and the TUI shows them. It uses the daemon on
the default socket, or --socket's. When none answers there it starts one
in its own process, as nodwyn app does, and says so before the full screen
opens (tui: serving tasks in this process on …; quitting ends them). The
console, another TUI or nodwyn run can then attach to it while it runs. A
daemon that answers but that this build cannot use (an older protocol) is
left running, and the TUI says so instead of starting a second one.
When the daemon's keychain is locked (one nodwyn app started, or one whose
passphrase was changed elsewhere), the TUI asks for its passphrase before
the full screen opens, masked, and unlocks it over the socket
(keychain.unlock). A keychain without a passphrase is opened by the daemon
without asking. An empty answer leaves it locked, and --no-keychain does
not ask: tasks then have no credentials until it is unlocked, here next time
or in the console.
Ctrl+Q detaches: the task and any turn go on in the daemon, for every client
and the scheduler, as when a console tab closes. Use Ctrl+C first to abort
the turn if that is what you want. When the TUI's own process serves the
daemon, quitting ends the daemon and with it the tasks it serves; their logs
remain, and a worktree with nothing unlanded is removed. The footer says
which of the two quitting does. :close closes the task in the daemon and
quits, as the console's Close does: its log is released, and a worktree with
nothing unlanded goes with its branch. It waits for a turn to end.
--show-thinking starts with the model's thinking shown, as :thinking
would. Flags belong after tui. Both standard input and output must be terminals;
use line mode (nodwyn chat) for pipes.
Everyday use
The initial list offers a new task in --repo, followed by stored tasks (a
task's log is its session; --resume and nodwyn sessions name it by that id).
Use arrow keys and Enter to open one; PgUp/PgDn move a page, Home and End
the first and last row. Typing narrows the stored sessions to those whose
first message, folder or worktree branch contains the text (Backspace edits
it, Esc clears it); the new-session rows stay. A selected stored session uses
its recorded repository and the core's resume rules. --resume skips the
chooser. Another process's writer lock remains authoritative; the TUI reports
an attach error rather than opening a second writer, and when --resume or
--worktree fails it shows the chooser, with the stored sessions, under that
error.
When --repo is a git repository's top folder (it has its own .git), the
first row, selected at start, is "New task in a new worktree of" it, as
the console starts a task in a git workspace by default (ADR 34). The session
then works in <repo>/.nodwyn/worktrees/<id> on a branch nodwyn/<id> from
the repository's HEAD; the conversation says so, with the number of
uncommitted files the worktree does not have, and the title shows the
branch. The next row opens in the folder itself. The .git check only
decides what is offered: core refuses a folder that cannot have a worktree,
with a sentence saying why. --worktree opens a new session in a worktree
at once, without the chooser; it does not combine with --resume. A stored
session in a worktree is listed with its repository and branch. Closing it
removes the worktree and its branch when nothing in them is unlanded, as in
every client. The TUI has no diff or review view, by decision (ADR 34,
amended 2026-10-08): git diff and an editor read the worktree at the path
the conversation gives, and a comment on the changes is a message.
The conversation shows user and model text, tool calls/results, guard outcomes,
plan scores, the steps the model marks done or skipped (plan-step),
attachments, document uploads, a build or refresh of the repository's index
([index updated: read 712 files · … · 1.3 s], under the call that made it)
and evaluation output. After each reply a
usage line gives its tokens and, when known, the share of the prompt read
from the provider's cache, its cost, the model's time and the request's
speed in tokens a second (12,400→800 tok ·
78% cached · $0.012 · 200.0 tok/s; the manual, "Choosing models"). The title
says how full the conversation's context is (context ~45k of ~61k: nodwyn's
estimate of the latest request against the window's high water), a
compaction is one line with the size before and after, and :budget adds
what the provider counted for that request and the compactions so far. Thinking is hidden by
default; :thinking toggles recorded reasoning and live thinking. Durable
response text replaces its streamed preview rather than appearing twice. The
log remains the source of truth; scrollback is a display derived from it.
A message that starts with /compact or /optimize runs that command
instead of an ordinary turn, as in every client (the manual).
:model shows the model the conversation talks to, its effort and the
efforts it takes, a choice waiting for the turn under way, and why a choice
could not be applied or the model is unavailable. It lists the models the
conversation may be moved to, the same list as the console's picker
([control] as control, then each [models.NAME]). :model NAME [EFFORT]
moves the conversation from its next turn (ADR 40, ADR 41). Naming the
current model changes only the effort, and a choice made while a turn runs
waits for that turn to end. The conversation shows each switch, or the
choice waiting, as a model → line. A refusal is the status line: a name
not configured, an effort the model does not take, or a conversation too
long for the target's context. A model given by a server and its id is not
offered, as the console's picker does not offer it. The TUI is always on
this computer, so the choice is never refused as a paired device's is.
:judge shows the judge the task asks (ADR 47): its model, host and price,
or that it has none, and why it cannot be asked when it cannot, with the
judges set up, the same list as the console's Judge select. :judge NAME
moves the task to that judge from its next question, and :judge none to
none; it is chosen while the task is idle, so a busy session says to wait.
Each choice is a judge → line in the conversation. Judges are set up in
the console's Settings › Judge, or in config.toml.
:attach PATH adds a file to the conversation (ADR 22). The rest of the
line is the path. ~/ is the home directory, and a relative path is from
the folder nodwyn was started in, as a shell would read it. Text is
extracted, redacted and capped as in every client, and the conversation
shows the attach line. An image asks its own security confirm, which naming
the file does not answer. It takes the screen like any other prompt, and the
attachment waits for its answer. Ctrl+C meanwhile cancels the attachment
instead of quitting. A message waits until the attachment is done, and a
busy session refuses one. The attachment is asked on a
connection of its own, because the daemon runs one connection's calls in
order and the confirm's answer could not pass the waiting call. The file is
the person's choice, as the console's file picker is. The daemon reads it
as the same user, and the model never names it.
The TUI also follows a run (nodwyn run): its
checkpoint arrives as an ordinary prompt (runs). A question with
named answers, such as the first upload's to Google Docs, lists them; type
one's key (ask, allow or deny) and Enter.
| Control | Meaning |
|---|---|
| Up / Down, PgUp / PgDn, Home / End, Enter | In the chooser: select and open a row |
| Typing, Backspace, Esc | In the chooser: filter the stored sessions, edit and clear the filter |
| Enter | Send the whole draft or submit the displayed prompt answer |
| Alt+Enter / Ctrl+J | Insert a newline in the message composer |
| Ctrl+O | While a turn runs: send the draft to run when the turn ends, after any message queued before it; the message shows as queued> under the conversation until it starts. With no turn running it sends, as Enter does |
| Ctrl+S | While a turn runs: stop the turn, as Ctrl+C does, and send the draft as the next message, ahead of those queued. With no turn running it sends, as Enter does |
| Up / Down | Move within the draft; the editor scrolls to follow the cursor |
| PgUp / PgDn | Scroll conversation or the current approval diff |
| Ctrl+End | Jump to the bottom |
| Ctrl+C | Abort busy work or an attachment, and drop the messages queued behind the turn; with an unsent draft, clear it; otherwise quit (also after a disconnect) |
| Ctrl+Q | Quit immediately: detach, or end the tasks when this process serves the daemon |
| Ctrl+R | Reconnect to the daemon and refresh history/prompts |
:eval EXPR |
Evaluate user-origin Scheme through the usual guard |
:thinking |
Show or hide thinking |
:log [n] [kind prefix] |
Inspect the last matching durable records (default 20, maximum 1000) |
:bindings |
Inspect loaded bindings and current command/write approvals |
:replay |
Read a transcript of the current session's durable log |
:friction |
Read docs/friction.md through the existing guarded Scheme path |
:plan |
Inspect the latest user message’s recorded planning outcome |
:budget |
Inspect configured limits, the latest submitted turn’s spend and its checkpoints |
:details |
Expand or collapse tool-call groups (see below) |
:tools [name] |
List model-facing tools; an exact name shows the input schema |
:model |
Show the conversation's model and the models it may be moved to |
:model NAME [EFFORT] |
Move the conversation to that model from its next turn |
:judge |
Show the judge this task asks and the judges set up |
:judge NAME, :judge none |
Ask that judge, or none, from the task's next question |
:index, :earlier |
Show the folder's repository index (files, size, when it changed) and whether the model may search its earlier tasks (ADR 50) |
:index forget |
Delete the folder's repository index; the next search builds it again |
:workflows |
The daemon's workflows as the console's Workflows page lists them: timetables (scheduled tasks) and those that watch pull requests, email or a command, each with a reference in brackets, the daemon's sentence, when it runs next or what it watches, what today's runs cost and the latest events (ADR 53) |
:workflows pause\|resume\|check\|run REF |
Act on the workflow or timetable whose reference is REF: pause or resume either, check polls a source now, run starts a timetable's run now |
:outbox |
The answers workflows hold for you: a review for a pull request or a draft reply to an email, each with its reference and the start of the answer |
:outbox post\|discard REF |
Decide one. The daemon posts it (a review comment, or a draft in the thread, never sent), with a key bound for exactly that call; a terminal does not change the words, which the console's Edit does |
:earlier on, :earlier off |
Let the model search the folder's other tasks, or stop it, from its next call; kept with the folder's name in workspaces.toml, as the console's Workspaces page keeps it |
:attach PATH |
Add a file to the conversation; an image asks first |
:allow |
Open the bindings/current approvals inspector |
:help, :h |
Show available controls in a scrollable view |
:q, :quit, :exit |
Quit using the same lifecycle as Ctrl+Q |
:close |
Close the task in the daemon and quit; a worktree with nothing unlanded is removed |
The composer is a separate multiline editor, with up to four visible rows.
Longer drafts scroll within it; terminal resizing does not truncate the draft.
Enter sends the entire message. Alt+Enter or Ctrl+J inserts a newline without
sending; Ctrl+J is useful when a terminal intercepts Alt+Enter. Bracketed paste
preserves line breaks and never submits, so multiline :eval expressions keep
Scheme comments on their own lines. CRLF/CR line endings in bracketed paste
become LF; the editor expands tabs to four spaces and removes control characters.
The underlying editor supports up to 10,000 logical lines.
A busy session retains an attempted submission as a draft: Enter while a turn
runs sends nothing, and the footer names Ctrl+O and Ctrl+S, which queue the
draft or steer with it. A message the daemon will not hold (a model switch is
under way, say) comes back to the composer with the reason in the status line.
A :eval is never queued. Prompts use their
own single-line editor; closing a prompt returns to the same draft and cursor.
Unsent drafts also survive daemon reconnects, but are not saved across exits;
a first Ctrl+C clears one rather than quitting. The footer says what stops a
running turn (Ctrl+C) and, after a disconnect, which keys leave.
Submitted operations are never retried automatically. Existing CLI commands
and line mode remain available.
Tool-call groups
Each model tool call has one transcript group. By default it is one line: the
tool's name, a summary of its arguments, the guard's verdict and its state.
The retrieval tools' arguments are said in words, as the console says them:
index-search “credential binding” in the repository, recall seq 812 of
task …, index-update the whole repository (ADR 50).
:details expands every group to the call ID, formatted arguments, guard
decisions and the full result or error, and collapses them again. The header reports the
recorded result status and elapsed milliseconds. While awaiting a result it
says so; if the interval ends without one, it says no result recorded.
PgUp/PgDn scroll through long arguments, results and standing-approval diffs.
All rendered fields pass through the existing redaction and terminal sanitizer.
Calls execute serially. The frontend joins a result only to the current open call with the same ID and name, so reused provider IDs do not overwrite earlier calls. A result without a matching call (including a skipped call) is displayed separately with arguments marked unavailable. It never fabricates an execution.
Guard records have no call ID. The group therefore labels them guard decisions
during this call, using only model-origin records within the open call/result
interval. This includes nested Scheme operations; it is not an inferred overall
permission verdict. Allows in a row of one tool by one checker are one line
(fs-read · allow ×712): an index build reads every file through the guard.
Those reads are the build's own work, so they do not give an index-search
or index-update its verdict; a file one left out is named on the build's
line instead. User-origin and out-of-interval decisions keep their existing
standalone presentation. Pending approvals still replace the transcript and
require an explicit answer.
History catch-up reconstructs the same groups as live delivery. Reconnect can
continue an open call; duplicate events are ignored by sequence. The durable log,
RPC schema, CLI replay and :replay output are unchanged.
Read-only inspectors
Inspectors replace the conversation viewport without stopping event delivery.
PgUp/PgDn scroll; r takes a fresh snapshot, and Esc returns to the conversation.
A pending approval, checkpoint or masked credential prompt always takes priority.
Late results cannot reopen a dismissed inspector or replace the prompt panel.
:log 50 guard. shows the latest 50 records whose kind starts with guard..
Filters operate on event kind; payloads are shown as indented JSON with sequence
and timestamp. :replay uses the same transcript renderer as CLI replay, with
no execution. Both read a fixed complete prefix through log.read/Store.ReadLog;
later pages retain the first page's watermark. They show which sequence the
snapshot ends at and do not append events. A partial tail reports an error;
refresh after an active writer finishes, or resume for crash recovery.
:tools lists names and descriptions from the session's model-facing catalog.
:tools NAME shows one tool's description and JSON input schema; names must
match exactly. This includes tools registered during initialization and excludes
ordinary Scheme builtins and hidden primitives. Inspection never evaluates the
tool, contacts its external service or grants permission to call it. The catalog
is captured when the session is constructed, so later edits to initialization
files do not change this view. It reads session.info's tools field. Normal inspector refresh,
scrolling, prompt priority and safe rendering apply.
:bindings (also :allow) shows the session's loaded class/destination/method/position rows
and its current effective command/write approvals. It reads a copied snapshot,
not the file on disk, and has no setter. Changes to bindings.toml reload
before the next egress check. Malformed policy denies egress; the inspector
continues to show the last validated snapshot. No stored credential values are accessed. An
older daemon without binding inspection reports that limitation instead of
showing a misleading empty table.
:friction needs an idle session and submits the fixed user-origin evaluation
(fs-read "docs/friction.md"). It therefore uses the same Scheme environment,
repository pin, registered guard checkers, read-size limit, cancellation and
redaction as ordinary reads. User extensions to fs-read also apply. It adds
normal evaluation and guard audit records, but source/results do not enter the
model conversation. Missing files, denied reads and escaping symlinks appear
as errors; the frontend never falls back to reading the filesystem itself.
The result is captured only after the matching evaluation request is observed.
Plan and budget snapshots
:plan shows the latest user message and its most recent recorded gate outcome,
and once the model has marked steps (plan.step), the plan's steps as
[x] done, [-] skipped or [ ] open, each with the model's note.
It distinguishes plan (recorded plan score and revision count), proceed
(probability that a plan was needed, not a plan-quality score), and degraded
(a fallback plan with no recorded score). The transcript uses the same labels.
A new message without a gate outcome does not inherit the previous message's
plan. No recorded outcome is shown as absence, never as success or an assertion
that the judge is disabled. Use :log for older outcomes and diagnostic notes.
:budget shows the task's own limits, those its next turn starts from:
"no limit" for each not set, which is every one unless the task was given
some (ADR 49, amended 2026-10-27), and its spend limit when it has one, the
same locally and over --socket. For the latest submitted
user-message or Scheme-evaluation turn it then shows what its model requests
cost, as far as the provider reported or the model's prices estimate (and the
share of prompt tokens read from cache), or that the spend is unknown and why,
and what the judge used (calls, tokens and, with its prices, cost) in that
turn and in the session. Each judge request is also one line in the
conversation. Then that turn's checkpoints: dimension,
usage, soft/hard limits at that point, all four usage counters, the spend so
far when known, files/commands, recorded choice or error, and actor when
available. Elapsed time is rounded to milliseconds. A recorded turn end is
identified separately.
These are checkpoint snapshots, not live counters, remaining allowances or
final turn totals. A turn without a recorded checkpoint has unknown usage here.
Checkpoint increases are displayed as recorded requests, never applied by the
viewer or carried into the next turn. On resume, old checkpoint limits remain
historical evidence while configured limits describe the current lifetime.
Pending checkpoints still use the existing reply panel, which now shows all
four usage dimensions. Opening either inspector records nothing, changes no
budget or permission, and never calls the judge. r refreshes and Esc returns.
Prompts and credentials
An approval temporarily replaces the transcript with the full, scrollable
operation description, reasons and diff. Type a displayed choice and press
Enter: y approves once; n declines. Eligible prompts also offer a for the
derived session scope and t for ordinary writes this turn. These use the
explicit scope-creation operation, never a generic yes answer. Security and
protected-path prompts do not offer wider scope. A budget checkpoint displays
its soft/hard limits and accepts only budget choices.
Credential prompts use a masked editor. For example, :eval
(credential-register "service-key") asks for the value separately, then gives
Scheme an opaque handle. The secret is never added to transcript or command
history. Submitting, canceling or disconnecting clears the editor; the reply's
transient byte copy is cleared after delivery. JSON and editor string storage
remain garbage-collected, as documented in the protocol. Empty input declines.
An unfinished ordinary message is kept separately while a prompt is displayed.
Reconnect and failure
A daemon connection failure clears the secret editor and disables submissions; Ctrl+C and Ctrl+Q quit even if a turn was running. Ctrl+R reconnects, resumes the selected session, loads a fixed log prefix through paged catch-up, then applies live events beyond that prefix. Prompt snapshots and live duplicates share IDs; an already resolved prompt is not shown again. Reconnect refreshes from the full history. It never resends a message, evaluation, approval or secret. If a reply was lost, inspect the recovered history and current prompt before deciding what to do next.
The RPC client has one socket reader, serialized writes, correlated responses and a bounded notification queue. Overflow fails the connection and requires catch-up; it never silently drops an approval or durable event. Server/repository text is redacted and stripped of terminal control sequences before rendering.
Maintainer map
internal/frontend/rpc/client.go: connection, handshake, request correlation, bounded event delivery and failure; it shares the server's wire types.internal/frontend/tui/backend.goandremote.go: the small operations used by these views, implemented over core or RPC. They own cleanup for their mode.model.go: UI state, commands, selection, resize and navigation.events.go: durable/ephemeral display reconciliation and safe terminal text.prompts.go: prompt presentation and explicit answer routing.composer.go: multiline editor setup, input routing and editor/viewport sizing.inspect.goandinspect_backend.go: snapshot viewers and their read paths.tools.go: model-facing tool list and input-schema presentation.models.go::model's view and the model choice over core or RPC.judges.go::judge's view and the judge choice over core or RPC.tool_calls.go: serial call/result grouping and guard/result presentation.plan_budget.go: recorded gate outcomes, checkpoint history and budget display.run.goandcmd/nodwyn/tui.go: lifecycle and command startup.
Core now exposes a copied binding-metadata snapshot (ADR 10 amendment). Scheme,
guard, keychain and log ownership are unchanged. Bubble Tea v2
uses the charm.land module path; the frontend boundary lint covers it as well
as github.com/charmbracelet so the new dependency cannot enter core.
Credential-bearing confirmations show the class, destination host, method and
injection position reported by the guard. Unavailable facts are labeled
“not specified by primitive.” Eligible unbound calls offer b: persist the
displayed class/host/method/position and approve this call. y approves once;
n denies. Missing or redacted scope and security prompts cannot create a
binding. Applied/failed outcomes appear in the transcript and replay. Line
mode offers the same choices. Older daemon prompts omit the binding action.
The initial client, multiline composer, tool discovery, read-only inspectors, tool/plan/budget presentation, guarded binding edits, worktree sessions, the model choice and attachments are implemented. P9 line-mode flow parity is complete; parity with the console is not, and clients.md records what each client does and what is still to build. Live budget counters are not exposed by the current API; the budget view explicitly reports recorded usage. The browser console and app window (browser, app) are other clients of the same daemon, which also drives runs (runs).
P9 acceptance coverage
The TUI covers the line frontend's user flows over the Unix daemon protocol; until 2026-10-17 it also ran them over a session in its own process (ADR 30, amended 2026-10-17). Line mode remains the default.
| Flow | Regression evidence in internal/frontend/tui |
|---|---|
| Send messages, evaluate Scheme, masked credential input, cancel | TestApproveRegisterAndCancelThroughTheDaemon, TestMultilineSubmissionThroughTheDaemon |
| One-time, standing-directory and turn approvals | TestStandingAndTurnApprovalFlows, TestSecurityPromptNeverOffersWiderScope |
| Persistent credential binding, failure, cancellation and reconnect | TestBindingPersistsExactlyAndPolicyReloads |
| Approval audit history without replaying authority | TestApprovalAuditIsVisibleAndDeduplicated |
| Tools, help, quit and approval-list commands | TestToolInspectionThroughTheDaemon, TestPlainCommandAliasesRespectPrompts |
| Log filtering, replay, bindings and guarded friction inspection | TestInspectorsWorkOverTheDaemon, TestLogFilterAndFixedPagination, TestFrictionInspectorHonorsGuardAndRepositoryBoundary |
| Thinking visibility and tool results | TestThinkingToggleRevealsAndHidesRecordedReasoning, TestToolCardsFromDaemonEvents |
| Plan and budget inspection, checkpoint replies | TestBudgetInspectionAndCheckpointThroughTheDaemon, TestPlanViewSeparatesGateOutcomesAndMessages |
| Resize, multiline paste, draft preservation and scrollable prompts | TestComposerNewlinesPasteResizeAndBusyDraft, TestContentCannotControlTerminalAndDiffScrolls |
| Abort kills shell children | TestAbortThroughTUIBackendKillsShellChildren |
Ctrl+Q leaves the task open; :close closes it and removes its clean worktree |
TestCloseEndsTheTaskAndItsCleanWorktree, TestTheFooterSaysWhatQuittingDoes |
| A task open in the TUI still runs on its schedule | TestATaskOpenInTheTUIStillRunsOnSchedule |
| A resumed task shows its whole conversation, past the first page | TestResumeShowsTheWholeConversation |
| The daemon's locked keychain unlocked over the socket | TestTUIUnlocksTheDaemonsKeychain (in cmd/nodwyn) |
| Reconnect restores history and pending prompts without resending | TestRemoteReconnectRecoversPendingPromptAndHistory |
Worktree row, --worktree, branch in the title and list |
TestChooserOpensAWorktreeThroughTheDaemon, TestChooserStillOpensInTheFolder, TestWorktreeFlagSkipsTheChooser |
:model, a switch the next turn asks, a choice queued behind a turn |
TestModelChoiceThroughTheDaemon, TestModelChoiceWhileBusyIsQueued |
| Ctrl+O queues the draft and Ctrl+S steers with it while a turn runs; Enter keeps it; a refused message comes back | TestQueueAndSteerKeysWhileBusy, TestRefusedQueueGivesTheDraftBack |
:attach of text and of an image whose confirm is answered while it waits; Ctrl+C cancels |
TestAttachThroughTheDaemon, TestCtrlCCancelsAnAttachment, TestAttachPathAndRefusals |
:judge, a switch, none, and a judge not configured |
TestJudgeChoiceThroughTheDaemon |
Real-terminal smoke checks supplement these tests for composer input, inspectors, tool cards, checkpoint choices, masked credentials and the persistent binding choice. These are operator checks, not human usability research.