wizard gui opens a window. One process, one binary, no webview, no loopback
HTTP, no port, and no JSON round trip per streaming token: the window links the
agent core in process and folds the same AgentEvents into the same
TranscriptModel the TUI reads.
It is the only GUI. It carries what the browser GUI carried — settings,
onboarding and OAuth, the git rail and diff pane, the session picker, the
subagent rail, the context meter, the todo checklist, the command palette, the
gate modals, the attachment tray and the image pane — and is past it in one
place: it can answer a command that asks a question (see
Console). It is short of it in
one: there is no model picker, only the settings sheet's model field and
/model (see What was ported, and what was
cut). It is the surface the webview shell
wizard app used to be, and S3 deleted that shell in its favour; the browser
GUI itself went afterwards (see What went with the browser
GUI).
--native is still accepted on wizard gui and does nothing. It named the
window back when a plain wizard gui served a page instead, and it is written
into every alias and README copy from that period; a hidden no-op flag is a
better answer than a clap error.
It is off by default, so it takes its own build. Two ways to get one:
# from a checkout
cargo build --release --features native
./target/release/wizard gui
# or through install.sh, which places a second binary, `wizard-native`,
# beside `wizard`
curl -fsSL https://raw.githubusercontent.com/teddytennant/wizard/main/install.sh \
| WIZARD_NATIVE=1 WIZARD_BUILD_FROM_SOURCE=1 bash
wizard-native guiWIZARD_NATIVE=1 on its own fetches wizard-native-<target>.tar.gz. Add
WIZARD_BUILD_FROM_SOURCE=1 to compile the window from the same checkout
instead of downloading it.
A build without the feature refuses wizard gui with a message carrying both
of those lines, plus the four ways to drive a machine you are not sitting at:
the TUI over SSH, wizard -p, wizard acp from an editor, and the Telegram
gateway.
Three reasons, and the third is the one that would be easy to lose:
- A terminal user never opens a window.
wizard -p,wizard acp,wizard gatewayand every CI container run this binary. Linking winit, a font stack and a software rasterizer into all of them costs several hundred crates of build time for code none of them executes. - Static musl builds. The default build has no display-server link of any
kind, which is what keeps
curl | bashworking on a headless box. - The coverage ratchet.
contrib/check-coverage.shmeasures with default features and gates on a line floor. A widget tree can only be exercised headlessly up to a point, and putting it inside that ratio would push the floor down for the whole codebase. Every line of iced code is behind#[cfg(feature = "native")]— including everythingsrc/plugins/gui/tasks.rsadds for the window: the two watcher fields,tap/untap/relay,attended, and the one line ofhandle_eventthat relays. That file carries no#[cfg]of its own beyond its test module; the gating is one level up, on the modules themselves —pub mod gui;andpub mod native;are both#[cfg(feature = "native")]insrc/plugins/mod.rs, so a default build never compiles either, compiles exactly the program it compiled before, and the ratchet keeps measuring the same thing.
Both directories live under src/plugins/ and the window registers itself with
the kernel instead of being called by name. src/lib.rs no longer mentions
either one: the wizard gui arm injects an entrypoint under the string "gui"
and prints the install instructions when nothing answers. See
plugins.md — "the window is a plugin, and it is the first one
that is not a provider" — for what that took. Nothing about the surface itself
changed, and --features native builds the same program.
The flag is a build decision, not a distribution one, and S3 had to answer the
distribution question separately: a flag that is off by default and ships
nothing means curl | bash installs a binary that cannot open this window,
which is the failure mode that made wizard app a subcommand printing an error
on 100% of installs.
So the release matrix builds it. .github/workflows/release.yml's native job
publishes wizard-native-<target>.tar.gz for the four gnu and darwin targets —
exactly where the four wizard-desktop-* assets used to be — and install.sh
with WIZARD_NATIVE=1 places one as wizard-native beside wizard. Two
binaries rather than one for one remaining reason: winit reaches X11 and Wayland
through dlopen, which a fully static musl binary cannot do, so there is no
musl native asset, and on a host where wizard itself is the musl build
replacing it would trade a binary that runs anywhere for one that runs here.
wizard update keeps the distinction (native_assets in src/update.rs): a
binary built this way updates to a native asset and never to the plain one.
What it costs the default build is nothing at all — cargo build with no
features still links no iced, and CI's --features native clippy-and-suite is
what keeps the graphical build from rotting between releases.
iced = { version = "0.14", default-features = false, features = [
"tiny-skia", "x11", "wayland", "canvas", "tokio",
"image-without-codecs", "highlighter", "lazy", "advanced",
] }Every one of those is chosen against something already in the tree:
| choice | why |
|---|---|
default-features = false + tiny-skia |
links no wgpu at all — not a runtime fallback, an absent dependency. cargo tree -i wgpu --features native prints nothing. |
image-without-codecs, not image |
image turns on the codec default set and would undo Wizard's png, jpeg, gif, webp selection. |
linux-theme-detection off |
it is in iced's default set and pulls mundy, which runs its D-Bus query on async-io — a second, smol-flavoured reactor beside the tokio runtime the agent core owns. The theme comes from crate::theme anyway. |
markdown off |
it pins pulldown-cmark ^0.12 against Wizard's 0.13, so enabling it links two parsers. src/plugins/native/widget/markdown.rs renders through the one Wizard already has. |
highlighter |
reaches syntect through two-face with syntect-default-fancy, byte-identical to Wizard's own selection. One syntect, one regex engine, no oniguruma and therefore no C dependency. |
cargo tree -d --features native shows exactly one syntect, one image, one
pulldown-cmark, and no wgpu, naga, mundy or async-io.
src/plugins/native/
├── mod.rs App, Message, update, view, run(), the screens
├── plugin.rs the kernel registration: `wizard gui` → run()
├── event.rs the tokio↔iced bridge, and the executor
├── font.rs Inter and JetBrains Mono, embedded
├── theme.rs crate::theme tokens → iced colours
├── command.rs the `/` menu, routing, and `impl CommandSurface`
├── console.rs the one gate this window claims
├── settings.rs the settings sheet, which is also onboarding
├── sidebar.rs the chat list
├── rail.rs git, the context meter, subagents, todos
├── pane.rs the diff, image and subagent panes
├── subagent.rs one transcript model per concurrent run
├── probe.rs test-only selectors
├── tests.rs the end-to-end proofs
├── select/ cross-block text selection
│ ├── block.rs what gets selected
│ ├── geometry.rs the cosmic-text bridge
│ ├── cache.rs shaped paragraphs, across layout passes
│ └── widget.rs the one widget, and the gesture
└── widget/
├── chrome.rs the label, the row, the quiet action, the one button
├── transcript.rs TranscriptModel → blocks
├── markdown.rs markdown → styled runs
└── composer.rs the field, and the send/stop control
(graph/ is the mesh explorer, behind --features graph along with the plugin
it draws from. It is deferred and unreachable in 2.0.0 —
it still compiles and its tests still run, but nothing in the window opens it.
See graph-explorer.md.)
Every module carries its own header explaining the part of the problem it
answers. Three are worth reading before touching anything: select/geometry.rs
(why it reaches past iced's own Paragraph trait), select/cache.rs (why the
cache is keyed by content and not by index) and command.rs (why there is no
second dispatcher).
gui-design-spec.md is the design record this window is
drawn from: the canvas, surface and hairline values, the type rules, the radii,
and a pane-by-pane description of what each screen shows. theme.rs, font.rs,
widget/chrome.rs and widget/markdown.rs all cite it by name, and for the
chrome values it is the only place they are written down — everything that is a
semantic colour comes from crate::theme's token layer instead, the same one
the TUI reads, so a palette change lands on both surfaces at once.
Read it as the brief and the record of where the drawing diverged from it, not as a description of what is on screen today. It predates this window and described the browser GUI as well; its (Native: …) notes and What was ported, and what was cut here are where the differences are kept, and its closing "Behavior to wire" section is the original wiring list rather than an outstanding one.
The point of S1's "salvage" line was that src/plugins/gui/ is not web-specific. It is
worth stating what that turned out to mean, because the split is not where a
file list would suggest:
| what | where it lives | what the window adds |
|---|---|---|
| providers, keys, presets, probing, the step limit | src/plugins/gui/settings.rs |
the sheet |
| OAuth sign-in (xAI, ChatGPT) | src/plugins/gui/oauth.rs |
two rows and a poll |
| git status, diff, branches, checkout | src/plugins/gui/git.rs |
the rail and the diff pane |
| sessions, keep-warm eviction, gates | src/plugins/gui/tasks.rs |
a second client |
| every slash command applied to a live agent | src/plugins/gui/command.rs |
nothing: it submits |
the chat list: merge, grouping, 2m |
src/session_registry.rs |
the sidebar |
| every slash command's semantics | src/commands/ |
impl CommandSurface |
Three of those moved during Phase 2 and are worth knowing about:
- The settings view model, the probe and every provider mutation were inside
src/plugins/gui/server.rs, between an axum extractor and an axum response. They are nowsrc/plugins/gui/settings.rs's, and the route handlers are argument shuffling and status codes. Nothing about a settings screen is web-specific. - The slash-command executor —
CommandCtx,GuiSurface,apply_commandand everything they reach — issrc/plugins/gui/command.rsrather than the back half oftasks.rs.tasks.rswas four thousand lines of two separable jobs (owning the sessions, and executing a command against one) and is the file everything needs; the house rule is to split it rather than grow it, and this phase gave it a third caller. - The chat list was half in the server's
list_tasksand half re-derived in the page's JavaScript. Merging the sessions on disk with the heartbeats, grouping by workspace and formatting an age are facts about the session store, so they aresession_registry::chats,group_by_workspaceandrelative_age— which is also what lets the TUI's/resumepicker share them later.
session_registry::claude_chats lists what Claude Code recorded for a
workspace, as the same ChatRow the rest of the picker is made of, and
ChatRow::origin says which store a row came out of. It lives beside the merge
for the same reason the merge does: "what sessions exist here" is a fact about
the stores, not about a window, and wizard resume --claude needs the identical
answer.
Three properties fall out of that placement, and each is a real constraint:
- Provenance is on the row, not inferred. A Wizard id and a Claude session
id are both uuids; nothing in the string says which is which.
Origin::Claudecarries the transcript's path, its leaf, and how many times it forked — everythingclaude_resume::importneeds — so no surface re-derives them and the two cannot drift. - Opening one is a different message. The sidebar emits
sidebar::Message::OpenClaude { source, leaf }, neverSelect(id), because the acts differ: one resumes a file Wizard owns, the other converts a file it does not. A singleSelectwith a branch further in would have made them the same call. - It is not on the refresh timer. Listing parses every transcript in the
project — tens of megabytes for a repository worked in for months — so the
sidebar's Claude section is folded shut and reads on open. What rides the
five-second timer is
session_registry::claude_here, a directory probe, which is also what hides the section entirely on a machine that never installed Claude Code.
There is exactly one chain walker (ClaudeSession::resolve_chain) and exactly
one importer (claude_resume::import); the CLI, the window and the route are
three callers of them. ~/.claude stays read-only, and that is enforced from
two directions: claude_session's source scan, which fails the build if a write
API is so much as named in that module, and
reading_a_claude_tree_leaves_every_byte_of_it_alone, which now drives the
listing and the import as well as the parser and compares the tree byte for
byte afterwards.
src/commands/ owns the table, the parser and dispatch, and
no_surface_hand_rolls_a_handler_that_shadows_the_registry scans all of src/
for a second match over SlashCommand. It is a text scan, so being behind a
feature flag does not exempt this window from it.
A typed line is therefore routed by the table's own column for Surface::Gui:
| column | where it runs |
|---|---|
Execution::Agent |
TaskManager::submit_command → the task's worker → dispatch against GuiSurface. The window writes no handler at all. |
Execution::Ui |
dispatch against native::command::Native, whose verbs return Actions the app applies. |
Execution::Unavailable |
also the window, so dispatch's own refusal — which says what the command is — is what the user reads. |
The window reports itself as Surface::Gui rather than adding a column of its
own. Every answer in a Native column would be a copy of the gui one — both
halves run the same agent, offer the same commands and refuse the same three —
and a duplicated column is a column that drifts. The column was the browser
GUI's; when that surface went, this one inherited it.
A custom .wizard/commands/*.md command routes as a message, because
preprocess expands it on the way into the turn on every surface. That needs the
workspace's command list, which is why route takes one: without it the parser
answers unknown command '/deploy' for every workspace command the menu had just
offered.
The window declares ConsoleAccess::Interactive and claims the console gate.
A browser could not: its user was behind a socket that could drop mid-command,
and a page holding the stdin of a live child in another process is a hung
apt install waiting on a tab somebody closed. That was a property of the
boundary, not of graphical surfaces — and it was the most-cited way the browser
GUI degraded against the TUI, because "wizard cannot run anything that prompts"
is a whole class of task.
This window is the process the command runs in, it dies when the agent dies, and
it has a person in front of it. So TaskManager::attended builds its tasks with
ConsoleAccess::Interactive, AgentEvent::ConsoleOpened reaches the window
through the tap, and the composer binds to the child's stdin until
ConsoleClosed. While it is bound the composer says which end it is typing into
and offers Ctrl-D — the keystroke as well as the button, which was not true
until 47462b0: the button read end input (Ctrl-D) while nothing anywhere
bound the key. It is in run's drops subscription (src/plugins/native/mod.rs), and
it is a no-op when no console is claimed rather than a binding that only exists
in one state.
This is the one gate the window claims, and the asymmetry is in
TaskShared::handle_event: it claims plan and interview gates and parks their
reply channels, and it does nothing at all with a console. Nobody else claims one
for a GUI task, so if the window does not, ConsoleHost::attended stays false
and the command dies at its timeout. Claiming is not taking the ticket from the
bookkeeping; it is the only thing that stops a prompting command from timing out.
TaskManager::with_registry, the constructor that does not claim a keyboard,
still leaves ConsoleAccess::None.
Files reach a turn by being dropped on the window. There is no upload,
because there is no boundary to upload across: a dropped path is already a path
the agent can open, and the verify_attachments that used to guard the upload
route defended an HTTP boundary that no longer exists anywhere. Images go to TurnRequest::images (the vision path),
everything else to TurnRequest::files, which the shared @file expansion
reads.
There is no file-picker button. A native picker means a new dependency (rfd,
which links GTK on Linux) for a gesture that drag-and-drop and @path already
cover, and adding a display-server dependency to this crate is the thing the
feature flag exists to avoid.
Inter and JetBrains Mono are embedded from assets/fonts/, as variable TTFs.
They are the same subsets the browser GUI served as woff2, decompressed, because
fontdb reads sfnt and not woff2; that directory is now the only copy. See
assets/fonts/README.md.
Font::MONOSPACE is not used anywhere the window draws. iced lets
default_font replace Font::DEFAULT but gives no hook for the generic
monospace family, which resolves through fontconfig to DejaVu Sans Mono — beside
the JetBrains Mono this build bundles. It compiles, it renders, and the only
symptom is that half the literals are the wrong typeface, so
no_block_the_transcript_produces_uses_the_system_monospace asserts it instead.
The agent emits AgentEvent on a bounded channel; TaskShared drains it and
hands each event to the one consumer that wants it:
Agent ──AgentEvent(256)──▶ TaskShared::handle_event ──▶ relay ──▶ tap ──▶ iced
└─▶ folds the dashboard row,
gates, queue, turn result
There used to be a second branch here — Frame ──▶ JSON ──▶ WebSocket, for the
browser GUI — and handle_event genuinely did fan out twice. It went with the
Frame enum and src/plugins/gui/ws.rs (see What went with the browser GUI below),
so what is left is self.relay(&event) and the fold beside it.
TaskShared::tap is what the window attaches, and it takes the event rather
than a rendering of it: AgentEvent is Clone + Debug, which is exactly iced's
bound on a Message, so it is the message — no wrapper enum, no From impl,
nothing to keep in step.
The stream side is a hand-rolled futures_util::stream::poll_fn over
UnboundedReceiver::poll_recv, rather than tokio-stream's wrapper: the
wrapper crate exists to do those four lines.
Unbounded, deliberately. The producer is handle_event, which is synchronous and
called from the turn's own drain loop — it cannot await a full queue, and it must
not drop from one either, because a dropped TextDelta is a hole in the
conversation nothing later repairs. Back pressure lives upstream, on the agent's
256-deep channel. This is the same choice the browser GUI's socket channel made,
for the same reason.
The executor is not iced's. iced's stock tokio executor is a
tokio::runtime::Runtime: it calls Runtime::new() and then block_ons the
compositor's creation on it. Inside wizard::run that lands on a thread already
inside the process's runtime, where tokio panics. native::event::Ambient hands
iced the runtime that already exists — spawns go to its workers, enter installs
its context, and block_on is the plain futures one, which is correct because the
only future iced passes to it is the software compositor's constructor, which
awaits nothing.
The kill criterion for this whole workstream: can a user select text across a
prose paragraph, a code block and a tool row, and copy it. Stock iced 0.14
cannot — Selection lives inside text_editor and text_input, both
single-buffer, and a drag across three stock widgets is three widgets each seeing
part of a gesture over text none of them shares.
So the transcript's runs stop being widgets. Selectable takes a Vec<Block>
and owns all of it: layout, hit testing, highlight, clipboard. One widget, one
gesture, and a range that spans three kinds of block is the ordinary case rather
than a special one.
The escape hatch. iced_graphics::text::Paragraph::buffer() is public and
cosmic_text is re-exported. That matters because iced's Paragraph trait is
lossy where it counts: hit_test returns Hit::CharOffset(usize), built from
the cosmic-text cursor's index with its line discarded. For a paragraph
holding one logical line that is fine. For one holding a hard newline it is wrong
in the worst way — a plausible small number — because index is relative to the
buffer line. Every code block has newlines. So does every tool row.
select/geometry.rs uses Buffer::hit for the full
Cursor { line, index, affinity }, and layout_runs() for per-visual-line
geometry the trait does not expose at all.
The trap. Buffer::hit clamps: it answers Some for a point far outside the
paragraph. A widget stacking N paragraphs therefore cannot ask each one whether it
was hit — every one says yes and every click resolves to block 0. The vertical
band dispatch happens in Selectable::anchor_at, which knows the offsets because
it laid them out; inside a band, the clamping becomes a feature, because a drag
that strays sideways should still select to the end of the line.
What it does: character, word and line granularity (single, double and triple
click, with drag-extend at the same granularity), drag in either direction across
any number of blocks, exact per-visual-line highlight including blank lines and
selected line endings, Ctrl+C to the standard clipboard, mouse-release to the
X11 primary selection, Ctrl+A to select the conversation, Escape to clear,
and a content-keyed paragraph cache so an insert mid-transcript costs one reshape
rather than N.
What it does not do: autoscroll on a drag that reaches the window edge
(scrolling belongs to the scrollable above it, and iced 0.14 gives a child no
way to move an ancestor), and selection across images or collapsed rows, which
are not text.
Size. select/ is 1,188 lines of implementation and 515 of unit
tests, plus the end-to-end selection tests in src/plugins/native/tests.rs. The
renderers that feed it are a further 251 (widget/transcript.rs) and 375
(widget/markdown.rs) of implementation — 411 and 489 with their own tests.
The spike's estimate for a finished text-only layer was 1,200–1,800 plus ~400
of tests; this lands just under the bottom of that range because autoscroll,
images and folds were cut to Phase 2 rather than because it was cheaper than
expected.
Three kinds of request can pause a turn:
| gate | this window |
|---|---|
PlanReady |
answered. The plan renders above the composer with approve/reject. Reject is two-stage: the first press reveals a feedback field, the second sends it — rejecting with no reason is the common mistake and it costs the agent the whole next turn. |
Interview |
answered. One field per question, plus Skip. Phase 1 declined these with a notice; the form is what Phase 2 added. |
ConsoleOpened |
claimed. See The console. |
The plan and interview tickets are still claimed exactly once, inside
TaskShared, and answered through resolve_plan / resolve_interview. The
window never calls claim() on either: doing so would take the reply channel out
of the bookkeeping that a turn's end and a disconnect both depend on. The console
gate is the exception and the reason is in src/plugins/native/console.rs.
Everything is proven without a display. iced_test's Simulator drives a widget
tree with no compositor, and iced::Renderer implements Headless, so the
software rasterizer produces real frames into memory.
One caveat, found by using it: Simulator::simulate hardcodes
clipboard::Null, so a copy test written on top of it passes whether or not
the copy happened. The selection tests therefore drive UserInterface directly
with a recording clipboard.
What the suite does not cover:
-
The window itself. No test opens one and nothing in CI does either — there is no Xvfb job anywhere in this repository. Compositor creation, winit event delivery, DPI scaling, IME, real font fallback, actual clipboard integration, real drag-and-drop and the system browser launch are all outside what the suite reaches.
It has been opened by hand, once. On 2026-08-07 it was run under a virtual X display and the screenshots were read, which found eighteen layout faults that no test asserts and no compiler sees: the graph explorer drew a lone node as a screen-filling disc,
18drendered as18cunder a floating scrollbar,removeandend input (Ctrl-D)were laid out at zero width and vanished, the diff pane wrapped where it should have scrolled sideways, andsettingsdrew across the sidebar's divider into the chat title. All are fixed (696d617…1030631), and most of them were one shape, now written on the helper that produced them (chrome::spread): aShrinkchild at index 0 is measured against the whole row and the fixed thing beside it gets the remainder, which can be zero, and a control given zero width does not clip — it disappears.Two things follow. The nineteenth will be found the same way, by hand, and macOS and Wayland have never had a window on them at all.
-
A real OAuth round trip.
SignInbinds a loopback listener and talks to a provider; the sheet's state machine is tested, the flow is not. -
A real provider probe. The sheet's rendering of a verdict is tested; the probe reaching a provider is not. The route test that used to exercise
settings::probeagainst a scripted endpoint went with the browser GUI, and nothing replaced it — this is a gap the deletion opened, named rather than papered over.
Now that the faces are bundled, a rasterized frame is a function of this
repository rather than of the machine: the same font bytes, the same wght axis,
the same tiny-skia rasterizer. the_bundled_fonts_rasterize_to_a_committed_digest
draws a prose-and-code fixture at 600×300 into memory and compares a SHA-256 of
the RGBA buffer against tests/fixtures/native/session.pixels.sha256.
Three deliberate choices in it:
- A hash, not a PNG: the image is 2.7 MB of RGBA and a binary that size in git says nothing a reviewer can read.
- The fixture is Latin-only prose and code. A
✓or a──would leave the two bundled subsets and land in whatever the machine falls back to — which is exactly the machine-dependence being removed — so the tool rows are covered by the structural snapshot instead, which is unchanged from Phase 1 and covers glyphs, indent, fill and chrome. - A missing golden fails. A snapshot that seeds itself when absent is a test
that can only ever pass. Re-bless deliberately with
WIZARD_BLESS_SNAPSHOTS=1 cargo test --features native.
-
The console really works.
a_prompting_command_is_answered_from_this_windowruns a scripted model that callsexecuteonread name; echo hello $name, claims the gate the task announces, types a line, and assertshello wizardin the tool's own output. Its companion asserts an ordinaryTaskManagerannounces no console at all, soattendedcannot be a no-op that passes both. -
Switching really replaces.
switching_chats_replaces_every_per_chat_fact_and_re_taps_the_feedfills a chat with a subagent run, a context reading, todos and staged files, switches, and asserts every one of them is gone and the generation moved. Carrying any of them across is another chat's facts under this chat's name, and nothing looks wrong while it happens. -
The feed is identified by what it should be.
the_event_feed_is_identified_by_the_chat_and_the_generationhashesFeeddirectly: the same task twice hashes the same, another task or a bumped generation does not, and a secondArcto the same task still does. -
A branched Claude Code session really opens, and really continues.
a_branched_claude_session_opens_from_the_picker_and_continuesbuilds a~/.claude/projects-shaped tree from the committed fixtures, lists it through the shared listing, draws the whole window, clicks the branched row, imports what the click asked for, opens the result, and runs a turn on it against a provider that records what it was sent. The last part is the one that matters: the fixture forks once, and the request body is the only place an import that took the wrong branch — or a flat top-to-bottom read that took both — is visible. It asserts a tool id from the resumed branch is present and one from the abandoned branch is not, then compares the Claude tree byte for byte against the snapshot it took at the start.The two asynchronous steps in that path (
App::open_claudeandApp::open_chat) have their bodies run by the test rather than by an executor, because there is no compositor to drive one. Everything either side of them is the product's.
S3 was written as "delete the JavaScript GUI". It deleted the webview shell
and kept the loopback server for one stated reason — remote access — and that
server has now gone too. Deleted: gui/assets/ (13 JavaScript modules, a
stylesheet, an HTML shell and two woff2 faces), src/plugins/gui/server.rs (the axum
router and every route), src/plugins/gui/ws.rs (the per-task WebSocket), the Frame
enum and the replay buffer in src/plugins/gui/tasks.rs, src/plugins/gui/transcript.rs (the
replay projection), docs/gui-protocol.md, and the axum dependency. The
--port, --no-open and --assets flags went with them.
Exit criterion 5 of the v2 plan — "Zero .js and .css in the repo" — now
holds literally. It had been restated once, to keep the page for remote
access; the restatement is withdrawn and the original criterion stands.
Remote access is no longer this surface's job. The claim the page was kept for — that binding 127.0.0.1 and serving HTML is the only way to reach a headless box — was true of graphical Wizard and only of that. What replaces it, in the order most people will want:
- The TUI over SSH.
ssh boxthenwizard. It is the surface every feature ships to first, it needs no port and no forward, and it is what the page was competing with rather than complementing. wizard -p '<prompt>'. One turn, no UI at all,--output-format jsonorstream-jsonif something is consuming it. See headless.md.wizard acpover the same SSH connection. An ACP editor on the laptop drives the agent on the box, in the editor's own chat panel — which is a graphical surface on a machine you are not sitting at, over stdio rather than over a port. See acp.md.- The Telegram gateway.
wizard gatewayon the box, an allow-listed chat on the phone. See gateway.md.
What none of those is: the window, on a remote machine. That is still the mesh's
job — a remote session as a peer whose node you trust — and driving a peer's
session is tier 3 (delegated work), which v2 cut. wizard peers watch can
watch a peer's session today and cannot type into one. The difference from
before is that this gap no longer costs a router, a page and thirteen JavaScript
modules to leave open.
wizard appandsrc/desktop.rs(1,088 lines, 12 tests), thedesktopfeature, andtao+wrywith roughly a hundred crates of GTK3 and WebKitGTK bindings behind them. It pointed a system webview at the loopback server: a worse version of this window that also neededlibwebkit2gtkinstalled, so it printed a "no desktop shell" error on every default install. Eight downloads across the eight releases that shipped it, against 132 for the plain Linux assets of v1.8.0 alone. The fourwizard-desktop-*release targets became the fourwizard-native-*ones, and WebKitGTK left CI,install.shand the flake.select_display_backend, which forcedGDK_BACKEND=x11because WebKitGTK under GTK 3 computed a negative device pixel ratio on a fractionally scaled Wayland output. It was a workaround for GTK 3 having no fractional-scale support, and it went with GTK 3: winit handles Wayland fractional scaling, so reproducing it here would force this window off the display server it works best on. It was the one always-compiled line of that module and had exactly one caller.
The four Phase-2 gaps this document listed, answered:
- The repo chip. Done. The sidebar's footer opens the list of workspaces
Wizard already knows about, and picking one moves where
New Chatopens. It is not a directory browser: a native picker means linking GTK, which is the dependency the feature flag exists to avoid, andwizard --cwd <path> guialready opens the window anywhere at all. (--cwdis a top-level flag, so it goes before the subcommand; after it, clap refuses the line.) - The branch chip. Done. It expands into the local branches and picking one
runs
gui::git::checkout— written for the browser GUI, and by the time that surface was deleted this was its only caller. No force and no stash: git's refusal to overwrite an uncommitted change is the safety property, so it is surfaced verbatim as a transcript notice. Covered bythe_branch_chip_checks_out_and_reports_a_refusal, which asserts the refusal as well as the switch. - The model picker. Cut, deliberately, and now gone rather than
pending.
/model <tag>and the settings sheet reach the same models, so this was a convenience gap and not a capability one. The browser's dropdown was fed byGET /api/models, which probed every configured provider over the network on every open; that route was deleted with the rest of the server and its logic was never lifted intosrc/plugins/gui/settings.rs. Building a picker here now means writing the probe fan-out, not moving it. - The feature flag. Answered: a second release artifact. See …and why off-by-default does not mean source-only.
Cut deliberately, and not blocking:
- A file-picker button. Drag-and-drop and
@pathcover it; a picker means linking GTK. See Attachments. - A launcher entry.
wizard app --installwrote a.desktopfile or aWizard.appbundle, and that went withsrc/desktop.rs. It was ~400 lines and 12 tests of freedesktop and Info.plist generation for a gesture a one-line.desktopfile covers, and it is not a property of the window. If the window earns one back it should bewizard gui --install, written against this surface rather than inherited from the webview's. GET /api/image. A window opens the file. The route is deleted.- Upload, and
verify_attachments. Both defended an HTTP boundary the window does not have. Deleted. - The page's markdown renderer. Not ported: the window has its own
pulldown-cmarkpass (src/plugins/native/widget/markdown.rs), with the syntax highlighting the JS never had. It also renders less, and the difference is worth knowing before you expect otherwise: the parser is opened withOptions::ENABLE_STRIKETHROUGHand nothing else (src/plugins/native/widget/markdown.rs:138), so no tables, no footnotes and no math. Theunicodeitpipeline that turns LaTeX into unicode is the TUI's alone (latex_to_unicodeinsrc/ui/mod.rs). The JavaScript is deleted. - The "MCP manager" S1 lists. There was no such surface in the browser GUI either.
Still open from Phase 1, and still open:
- Images are named, not drawn, in the transcript. They are drawn in the image pane, which is where a person looks at one; inline thumbnails need a widget the selection layer can host without leaving a hole in the drag.
- Collapsible tool rows, for the same reason and with the same constraint.
- Right-aligned user bubbles.
Blockstill has no alignment field. - Autoscroll during a drag.