Skip to content

feat: plate tui — a full-screen terminal UI (optional [tui] extra) - #97

Merged
DLANSAMA merged 15 commits into
mainfrom
feat/tui
Aug 5, 2026
Merged

feat: plate tui — a full-screen terminal UI (optional [tui] extra)#97
DLANSAMA merged 15 commits into
mainfrom
feat/tui

Conversation

@DLANSAMA

@DLANSAMA DLANSAMA commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What this is

plate tui — a full-screen Textual terminal UI, shipped as an optional extra (pip install 'platecli[tui]').

  1. Dashboard (read-only): printer state, temperatures, progress, AMS trays over StatusService thread workers.
  2. Prepare screen: the wizard core extracted to bambu_cli/interactive/core.py and shared, so go and tui cannot drift. Source validation, material/quality presets with the AMS-loaded filament pre-selected, supports, slice + time/filament preview.
  3. Confirm modal + live monitor: one single confirm=True call site in the whole TUI; the monitor follows a print to a terminal state.
  4. Polish: help overlay (? / F1), per-screen honest footers, 80×24 proofs, docs.
  5. Advanced slice settings (s on prepare): the named slice flags as a grouped form, plus a searchable browser across every setting in the installed OrcaSlicer profiles.

It is a front-end over the existing pipeline, not a second implementation.

Settings: picked, not typed

The settings screen originally asked you to hand-edit a KEY=VALUE string with a filament: prefix to remember. That is gone. The shape of each control is now derived from the installed profiles: a new setting_value_domains() collects every distinct value a key takes, and editor_for() maps that to a toggle (0/1), a dropdown (a short closed set), a number box, or free text. Picking a key fills its name, shows the profile's own value, and pins the process/filament bucket to the profile the key came from.

Three things that fell out of testing and matter more than the widgets:

  • Observed values are a subset of what OrcaSlicer accepts. Profiles that only ever say grid must not make gyroid unreachable, so every dropdown carries a type a custom value entry. The inference is a shortcut, never a cage.
  • The bucket picker was sticky — routing one key to filament made the next unclassifiable key inherit filament, the silent no-op this split exists to prevent, in reverse. It resets to process per key.
  • An untouched dropdown recorded key=, sending an empty override. Blank means "not chosen" and is refused; an empty text value is still allowed, since clearing a setting is legitimate.

Visual pass

The screen was rendered to SVG and inspected, which caught four defects no assertion could:

  • Rich markup was eating the bucket tag[filament] key = 0.98 rendered as key = 0.98. Prompts now pass Text, which also protects bracketed profile values ([0.98]). Guarded by asserting the prompt type, since asserting the string cannot fail.
  • Every screen's header said "printer dashboard"Header watches screen.sub_title and only the App set one. Pre-existing across the whole TUI.
  • Density: a stacked label over a bordered input showed five of 25 fields at 100×30 and four at 80×24. Label and control now share one flat row — three whole groups at 80×24.
  • Empty statics reserved padding, leaving a gap mid-editor.

Safety

Unchanged from the CLI, and deliberately so:

  • A print starts only from the confirm dialog — the single confirm=True call site, guarded by modality plus a synchronous _job_running flag.
  • Upload-only leaves the file unstarted; cancelling preserves the sliced file and says where it is.
  • Leaving the monitor never stops a print; quitting is refused while an upload is in flight.
  • Every override goes through the same _validate_slice_options the CLI runs.
  • Interactive-only, like go: --json or a non-TTY stdin exits 5 with the standard error envelope (schema docs/schemas/tui.json).

plate go is untouched and remains the no-extra-dependency path for SSH, dumb terminals, and screen readers.

Gates (measured locally, Linux)

Gate Result
ruff check / ruff format --check passed / 79 files formatted
mypy -p bambu_cli no issues, 79 source files
bandit -ll 0 medium, 0 high
pytest -W error::ResourceWarning -m "not live" 1309 passed, 1 deselected
coverage 88.54% (floor 83)
smokes syntax, cli_help, ci_workflow, python_compat, dependency_resolution, privacy, agent_cli — ok

tui/screens/settings.py 99.1% with zero uncovered statements; tui/settings_model.py 100%.

Every new guard is sabotage-verified — each test was re-run with the thing it protects removed, and each one failed. That process caught a vacuous test: the async-Input.Changed guard passed with the guard deleted because it used a number-valued key; only a dropdown key discriminates, since re-running set_options is what resets a Select.

Why this is still a draft

The settings screen has not been exercised by a human against a real printer. Phases 1–4 were live-tested; everything from Phase 5 onward has only been driven by pilot tests and inspected as rendered screenshots. That live test happens before this leaves draft.

textual is pinned >=0.86,<2.0 on purpose — 8.2.8 makes three dashboard pilot tests fail (run_test() text extraction returns empty); tested green on 1.0.0. Don't widen that pin without re-running tests/test_tui_dashboard.py.

Known accepted quirks

  • m on an idle printer instantly reports terminal IDLE — faithful to monitor_status.
  • Job stdout is discarded on success, surfaced only in the failure tail.
  • SIGINT is not quit-guarded (Textual default).

DLANSAMA added 15 commits July 30, 2026 17:10
New optional full-screen TUI (lazygit-style) as a sibling front-end to the
go wizard, per docs/plans/tui-plan.md. Phase 1 ships the entry point and a
read-only status dashboard:

- `plate tui` subcommand: lazy-import wrapper (commands/tui_cmd.py) over
  bambu_cli/tui/; in LOCAL_COMMANDS so it can render its own guidance when
  unconfigured. Interactive-only contract mirrors go: --json emits the
  error envelope (exit 5, failed_step parse, schema docs/schemas/tui.json)
  and non-TTY stdin is refused the same way.
- DashboardScreen with status + AMS panels fed by StatusService (blocking
  status() calls in a thread worker); r refreshes, q quits; unreachable
  printer renders an inline error state instead of crashing.
- Textual ships as the optional `tui` extra, pinned >=0.86,<2.0: 8.x
  breaks pilot text extraction in run_test() (3 dashboard tests fail on
  8.2.8), so the cap stays at the tested 1.x line — see the pyproject
  comment. pytest-asyncio (asyncio_mode=auto) drives the pilot tests.
- styles.tcss ships in the wheel (package-data + MANIFEST.in), verified by
  package_contents_smoke plus a manual wheel listing.

Gates: ruff check/format, mypy (71 files), bandit, full suite 1157 passed
at 86.21% coverage (floor 83), ci_workflow/syntax/cli_help/privacy/
python_compat smokes, uv build + package_contents_smoke all green.
Extract the wizard's decision logic out of interactive/session.py into a
new front-end-agnostic interactive/core.py, then build the TUI prepare
flow (source -> presets -> slice -> preview) on top of it.

Extraction (session.py 750 -> ~330 lines, behavior byte-identical;
test_interactive_session.py and test_wizard_guided.py pass unmodified):
validate_source, material/quality choices + guidance, AMS detection
(incl. the single-arg detector fallback), build_job_namespace,
run_prepare_pipeline, preview_rows (preserves the 11-char label pad),
slicer preflight checks, workdir hygiene helpers, WizardState/GoSteps
moved verbatim. session.py aliases the old private names so its module
globals and existing imports keep resolving.

TUI side: PrepareScreen (source Input with inline validation, material/
quality RadioSets with guidance, supports Checkbox, AMS-detected default
with '(detected in AMS)' tag, preview honoring the pre-sliced caveat),
PreflightErrorScreen pointing at plate setup, PipelineService driving
download -> extract -> slice via the injected GoSteps in a thread worker.
Print action stays disabled: no TUI path reaches confirm=True until the
Phase 3 modal (adversarially verified).

Three bugs found in adversarial review and fixed with guarding tests:
- Escaping mid-prepare leaked the temp workdir (worker owned the dir;
  now the screen creates it up front and on_unmount discards it even
  when the result never lands; late worker completion is defensive).
- Late AMS detection silently overrode a manual material choice made
  while the MQTT read was in flight; detection now yields once the user
  has touched the RadioSet (programmatic pre-selection exempt via a
  sentinel, since Textual delivers Changed asynchronously).
- Message-less prepare failures rendered as 'Printer unreachable';
  the prepare path now has its own fallback wording.

Gates: ruff check/format, mypy (73 files), bandit, full suite 1205
passed at 86.81% coverage (floor 83), syntax/cli_help smokes green;
docs test-count snapshots refreshed (drift guard green).
The TUI can now start a print, behind a single explicit gate, and watch
it live. Full flow: dashboard -> n -> source/presets -> prepare ->
confirm -> monitor -> terminal state, covered end-to-end in a pilot test.

- ConfirmModal (Start print / Upload only / Cancel) is the ONLY code
  path in the TUI that builds a job namespace with confirm=True — one
  grep-able call site, adversarially verified: double-press, modal
  bypass, and re-entry attempts all reach the job runner at most once
  (synchronous in-flight flag set before any await point).
- Workdir ownership is one-owner-at-a-time, documented in confirm.py:
  prepare owns until take_result() hands off to the modal; job success
  and cancel clean up (cancel first preserves the sliced file with the
  wizard's own 'Nothing sent. Sliced file kept at ...' message — now a
  shared core.decline_message the wizard also calls, so wording cannot
  drift); Esc hands ownership back with the preview intact; job failure
  keeps the file for retry; quitting with the modal open cleans up.
- MonitorScreen + MonitorService poll the status provider until a
  terminal gcode_state; the terminal set is imported from
  protocols/mqtt.py (hoisted to TERMINAL_GCODE_STATES, no behavior
  change) and identity-asserted in tests so it cannot fork from the CLI
  monitor. Esc detaches without sending anything to the printer.
- Quit guard: ctrl+q (the only binding that pierces the modal) is
  refused while a job upload is in flight; every quit path routes
  through the one action_quit.
- Job worker wraps cmd_job in redirect_stdout/stderr so Rich logging
  can't draw over the UI; on failure the last lines of captured output
  are appended to the error (bounded, markup-safe).
- Fixed a latent Phase 2 bug: screens stored _closed/_running, which
  shadow textual.MessagePump internals (the modal's buttons would have
  been dead); renamed to _left_screen/_job_running and swept the whole
  package against the Textual base-class attribute set.

Adversarial verification round-tripped twice: the first pass confirmed
the safety design but caught a vacuous quit-guard test (plain 'q' never
reaches the app under a modal — the test passed with the guard deleted);
it now presses ctrl+q and fails when the guard is sabotaged.

Gates: ruff check/format, mypy (76 files), bandit, full suite 1223
passed at 86.94% coverage (floor 83), syntax/cli_help smokes, no leaked
workdirs under -W error::ResourceWarning.
…hardening

Final build phase; the TUI is feature-complete and documented.

- Help overlay on ? (and F1, because ? is a printable char the source
  input legitimately swallows on the prepare screen): pure-presentation
  HelpScreen listing every key, closing on Esc/?/F1/q — q closes the
  overlay rather than quitting. A drift guard asserts every key the
  overlay documents is actually bound somewhere.
- Footers made honest per screen: app-level bindings are active
  everywhere but hidden; each screen's Footer advertises only keys that
  work there (the confirm modal previously advertised nothing).
- 80x24 proofs: pilot tests run the full flow at size=(80,24) with deep
  paths and a ~500-char slicer error, asserting overlay/preview widths
  stay within the terminal.
- Outcome-stranding bug found by adversarial review and fixed at two
  independently-tested layers: a job finishing while an overlay covered
  the confirm modal had its dismiss silently rejected (frozen modal,
  dead buttons, monitor never opening). action_help now refuses while
  the top screen is mid-job, and _job_done pops anything above the
  modal before dismissing — either layer alone passes its test.
- Docs: manual.md 'Full-screen mode (plate tui)' section (screens, key
  table, safety model, go-vs-tui positioning), README mention,
  CHANGELOG Unreleased entry, quality-roadmap coverage + tracking rows.
- Coverage hardening (tests/test_tui_polish.py, 34 tests): tui package
  now 95.7-100% per module; repo total 87.89% (floor 83). Tests also
  gained an isolated-cwd fixture — the decline path deliberately moves
  the kept file into cwd, and tests had been littering the repo root.

Gates: ruff check/format, mypy (77 files), bandit, full suite 1257
passed at 87.89% coverage, syntax/cli_help/ci_workflow smokes, uv build
+ package smoke (help.py + styles.tcss in the wheel), privacy smoke
zero tracked hits, python_compat smoke, no leaked workdirs.
…he slicer)

The prepare screen gains an opt-in Settings screen ('s' / a button)
exposing the CLI's entire slicing surface — requested after the live
test. No new slicing machinery: everything rides the existing
--set/--set-filament plumbing and named slice flags.

- Grouped form (Quality / Strength / Supports / Adhesion / Filament /
  Speed / Plate) of 25 fields, each mapping 1:1 onto a real slice
  parser dest — pinned by a test that introspects build_parser(), and
  choice fields (seam position, ironing) derive their option sets from
  the parser so they can never drift from what the CLI accepts. Blank
  field = profile default, exactly like leaving the flag off.
- Searchable browser over every setting in the installed OrcaSlicer
  profiles, via a new slicer.options.setting_catalog seam that
  slice --list-settings now also reads through (one discovery path,
  output unchanged). The override bucket comes from the profile the
  key was found in, which is what routes filament_flow_ratio to the
  filament profile instead of silently no-oping as a process key —
  proven by a read-back test that runs the real cmd_slice against the
  hermetic orca stub and asserts the values inside the temp profiles
  the stub received. With no readable profiles (e.g. --sim) the
  browser degrades to free-form KEY=VALUE entry with an explicit
  filament:/process: prefix for routing.
- Safety unchanged: overrides go through the same
  _validate_slice_options bounds the CLI applies (nozzle 999 refused
  inline, from the form and from the browser path), and the slice-time
  validation remains the backstop.
- Wizard untouched: empty overrides return the identical namespace
  object (sabotage-tested), and plate go's tests pass unmodified.
- Adversarial review caught two things fixed here: the s keybinding
  bypassed the disabled Settings button on pre-sliced sources
  (discarding a ready-to-print workdir for settings that could never
  apply — now both doors share one settings_lock_reason), and the
  seam-position hint suggested a value the CLI parser would reject.
- Changing settings after a preview discards the stale workdir and
  requires re-preparing; overrides survive the confirm round-trip.

Gates: ruff check/format, mypy (79 files), bandit, full suite 1296
passed at 88.35% coverage (floor 83), syntax/cli_help/ci_workflow
smokes, docs-consistency green with refreshed counts.
`class SettingsScreen(Screen[SliceOverrides | None])` is a PEP 604 union in a
RUNTIME position: a class base is evaluated eagerly, so `from __future__ import
annotations` does not defer it, and `type | None` raises TypeError before 3.10.
The 3.9 CI leg died at collection (1262 collected / 1 error); the other five
legs passed, Windows included. Now `Screen[Optional[SliceOverrides]]`.

python_compat_smoke.py could not have caught this, for two independent reasons:
it returns early on any module carrying `from __future__ import annotations`
(nearly all of them), and it only ever walked annotation nodes, never class
bases. Adds `_check_pep604_runtime` with two rules picked to have no false
positives on real bitwise code — any `|` inside a class base, and any `X | None`
outside an annotation. Both sabotage-verified: reverting the fix fails the
smoke; the seven legitimate `os.O_CREAT | os.O_EXCL` / set-union expressions in
the package stay silent. The remaining gap (a runtime union of two non-None
types outside a class base) is documented in the docstring rather than implied
to be covered.

Verified on a real CPython 3.9.25 interpreter, not by inference: every
bambu_cli module imports, and the full suite is 1296 passed / 88.38% coverage.
Documentation pass over every tracked doc. Three corrections matter beyond
refreshing numbers:

SECURITY.md described `--confirm` as the gate for physical actions. That is true
of the non-interactive commands and NOT of `plate go` / `plate tui`, where the
user never types the flag and the gate is a confirmation dialog. Verified before
writing: both front-ends refuse `--json` and a non-TTY stdin with exit 5
(session.py:343-356, tui/entry.py), and each has exactly one `confirm=True` call
site (session.py:291, tui/screens/confirm.py:108), so the property the flag
protects still holds -- only its form differs.

AGENTS.md had a paragraph sitting between two rows of the quality-gates table,
which splits it in half wherever markdown is rendered; AGENTS.md ships in the
sdist, so that was visible on PyPI. Moved below the table. The module table had
also drifted seven entries behind the package: added `interactive/`, `tui/`,
`tlspin.py`, `netsafety.py`, `printables.py`, `ams.py`, `utils.py`, and marked
`go`/`tui` as human-only surfaces with no machine contract.

CONTRIBUTING.md listed four of the eight smokes CI runs, so a contributor could
run everything the doc named and still go red -- which is exactly how the 3.9
break reached CI. All eight are listed now, with the PEP 604 runtime-position
trap written out, and the PR template gained the compat smoke as a checkbox.

Also: troubleshooting gained the three `plate tui` symptoms (missing extra,
interactive-only refusal, empty settings browser + the `filament:` prefix that
avoids a silent no-op), keyed to the strings the command actually prints; docs/
index now lists troubleshooting.md and plans/; the TUI plan is marked implemented
rather than "draft"; releasing.md verifies the `[tui]` extra resolves, since a
broken optional extra is invisible to the default install path; live-printer-smoke
records that the interactive front-ends are hand-tested only.

Numbers refreshed from measurement, not inference: 1296 passed / 1297 collected,
88.35% local Linux, and CI run 30632442521 giving Windows 88.09% / Linux 88.51% /
macOS 88.33%. Windows is still the binding leg. Schema count corrected 25 -> 26
(tui.json). Added ratchet headroom to the roadmap: 85 is supported by this data,
88 is not (Windows clears it by 0.09pt).
The advanced-settings screen asked the user to hand-edit an override string:
select a key, get `key=value` dropped into one text box, edit around the `=`,
and remember a `filament:` prefix for the degraded case. Four of the named
flags with closed option sets (wall type, support type, seam position, ironing)
were free-text boxes you could typo into.

Now the shape of the control is derived from data. New
`setting_value_domains()` in slicer/options.py collects every distinct value a
key takes across the installed profiles -- deliberately separate from
`_known_setting_keys`, whose `{key: representative_value}` contract feeds
`slice --list-settings` and must not change shape. `editor_for()` turns that
domain into a control: `0`/`1` is a toggle (checked before "numeric", or every
OrcaSlicer toggle renders as a number box), all-numeric is a number box, a short
set of short strings is a dropdown, everything else stays free text. Picking a
key now fills its name, shows the profile's own value, pins the bucket to the
profile the key came from, and seeds the editor. Pending overrides are a list
you can click to edit or remove individually.

The inference is a shortcut, never a cage -- three things that fell out of
testing and matter more than the widgets:

* Observed values are a *subset* of what OrcaSlicer accepts. Profiles that only
  ever say `grid` must not make `gyroid` unreachable, so every dropdown carries
  a "type a custom value" entry. Caught by a test that tried to set a legal
  value the local profiles happen not to use.
* The bucket picker was sticky: routing one key to filament made the *next*
  unclassifiable key inherit filament, which is the silent no-op this split
  exists to prevent, only in reverse. It now resets to process per key.
* An untouched dropdown was recorded as `key=`, sending an empty override. Blank
  in a dropdown means "not chosen"; it is refused. An empty *text* value is
  still allowed, since clearing a setting is legitimate.

All four guards sabotage-verified (each test fails with the guard removed),
including the async-`Input.Changed` no-op guard -- whose first test was VACUOUS:
it used a number-valued key, where reconfiguring does not touch the value. Only
a dropdown key discriminates, because re-running `set_options` is what resets a
Select. Same vacuous-test trap as the Phase 3 quit guard.

`parse_override_entry` is deleted with its tests -- the screen was its only
caller and there is no string syntax left to parse.

1307 passed / 88.53% (up from 88.35%); tui/screens/settings.py 99.0% with zero
uncovered statements, tui/settings_model.py 100%. Manual, troubleshooting (the
`filament:` prefix entry written this morning is now wrong and was rewritten),
CHANGELOG and the measured roadmap/backlog numbers all updated.
Phase 4 proved every other screen at the smallest supported terminal but not
this one, and the editor just gained a bucket dropdown, three value controls
and a second list. Asserts the widgets stay within 80 columns and that an
override can still be added and applied there — rendered is not the same as
usable.
…ating tags

Rendered the real screen to SVG and looked at it. Four things were wrong that
no assertion was going to catch.

**The bucket tag was invisible.** OptionList prompts passed as `str` are parsed
as Rich markup, so "[filament] filament_flow_ratio = 0.98" rendered as
" filament_flow_ratio = 0.98" -- the process/filament fact the browser exists to
show, silently eaten. Tests could not see it because the prompt *string* is
intact; only the render differs. Both lists now pass `Text`, which also protects
any bracketed profile value (list-valued settings render as "[0.98]"). Guarded
by a test asserting the prompt type, since asserting the string cannot fail.

**Every screen claimed to be the printer dashboard.** `Header` watches
`screen.sub_title` and only the App set one. Settings, prepare and monitor now
name themselves.

**The form was unreadable.** A stacked label over a default (bordered, 3-row)
input meant a 30-row terminal showed five of 25 fields, and four at 80x24. Label
and control now share one flat row -- 17 fields at 100x30, three whole groups at
80x24. Select needed its inner SelectCurrent flattened too (own border,
background and padding) or dropdown rows broke the rhythm.

**Dead space in the editor.** The profile-value note reserved its padding while
empty, leaving a gap mid-editor; it now collapses. The value control moved into
the same one-row layout as the rows above it, and the pending list shrank, so
the whole editor plus both button rows fits on one 80x24 screen.

Also gave the eleven fields that had no hint an example value -- an empty value
column read as an inert field rather than an empty one.

1309 passed / 88.54%; settings.py 99.1%, settings_model.py 100%. ruff, format,
mypy, bandit and the smokes all green.
Review pass against the real stock Bambu profiles, not a fixture. The toggle
inference required only that every observed value be in {0, 1}, so a key seen
holding "0" in every installed profile rendered as a switch. Measured: 35 keys
were inferred as toggles and only 6 ever actually varied between 0 and 1.

The other 29 are numbers sitting at zero, and the switch made every value except
0 and 1 unreachable -- with no custom escape, since only the dropdown has one.
Real casualties: raft_layers and skirt_loops and skirt_height (counts),
support_filament and support_interface_filament (AMS slot indices, so you could
not select slot 3), bottom_shell_thickness, max_bridge_length,
max_travel_detour_distance, support_expansion. This is the same "picker as cage"
bug fixed for dropdowns one commit ago, missed for switches.

A toggle now requires BOTH states to have been observed. That leaves exactly the
6 keys that genuinely vary -- enable_support, filament_is_support,
filament_soluble, activate_air_filtration, reduce_fan_stop_start_freq,
support_interface_loop_pattern -- and moves the rest to a free number box.
Sabotage-verified: restoring the old subset rule fails both new tests.

Also locks the "Applies to" dropdown for a key found in the profiles. The bucket
is decided by `bucket_for_key` from the source profile, so the control was
accepting a choice and silently discarding it. It reports the fact now, and
unlocks for a key nothing can classify, where the user's choice is the only
signal there is.

Measured on the real profiles (641 files, 176 keys): catalog loads in 12 ms cold
and 0.1 ms warm, so reading the profiles a second time for value domains costs
nothing at mount. Editor split is now 125 number / 36 select / 6 switch / 9 text.

1312 passed / 88.55%; settings.py 99.1%, settings_model.py 100%. ruff, format,
mypy, bandit, all eight smokes and package_contents green.
Rendered all six screens and looked at them as a set. The settings screen had
been through this; the rest never had.

**Modals looked broken.** `border: thick $primary` renders as solid blue slabs
across the top and bottom, so the help overlay and the confirmation dialog read
as damaged widgets rather than frames. Both are `round` now, matching every
other panel, and both carry a border title.

**The confirmation dialog showed a temp path and nothing else.** It is the one
screen in the app that starts a physical action, and it could not tell you what
it was about to print. It now renders the preview rows the prepare screen has
already computed -- model, printer, material and quality, time and filament
estimate -- above the path. No new computation: `PrepareResult.rows` simply
travels to the modal.

**The dashboard was two unlabelled boxes stretched to full height**, each mostly
void, with the content pinned to the top. The panels are titled ("Printer",
"AMS") and hug their content now.

**A running job had no bar on the dashboard** even though the monitor has had
one all along. `JobProgress` gained a `compact` mode -- bar only -- because the
full table repeats state, progress and layer, which the printer panel is already
showing. It appears for RUNNING and PAUSE only: a 0% bar on an idle printer
reads as a stalled print. `_ACTIVE_STATES` is deliberately not "not terminal",
which would include PREPARE where a percentage means nothing.

Also removed a duplicate `#browser-list` rule left by the settings work, where
the second silently overrode the first.

Both new behaviours are guarded, and the hide-when-idle guard is
sabotage-verified. 1312 passed / 88.58%; every tui widget at 100%. ruff, format,
mypy, bandit, smokes and package_contents green.

Known remaining: the prepare screen wastes its right half -- the form sits in a
narrow left column while ~40% of the width is empty. That needs a two-column
layout, which is a real restructure rather than a style fix.
A str cell in a Rich table is parsed as markup. Every one of these sinks
renders data the printer or the user supplies, so a model named
"bracket [remix].stl" rendered as "bracket .stl", and one shaped like
"a[/b]c.gcode" raised MarkupError in the middle of a render.

Wrap the cells in Text() at the three sinks that take outside data: the
status panel's label/value rows, the AMS tray table, and the confirm
modal's summary. Tests assert the rendered cell, not the source string —
the string was always intact; it is the render that dropped the tag.
The settings screen grew a searchable browser over every key in the
installed OrcaSlicer profiles, with the editor control inferred per key
from the values those profiles happened to hold — a switch for 0/1, a
dropdown for a short observed set, otherwise a number or text box.

It worked, and it was the wrong thing to ship. The inference is a tuned
heuristic over a third-party tool's vocabulary (_MAX_SELECT_CHOICES=12,
_MAX_CHOICE_LEN=40, and a 0/1 rule that already needed its own bugfix
after caging raft_layers and every other constant-zero count at 0/1). It
drifts silently whenever OrcaSlicer ships new profiles, and no test can
catch that drift. It also generated most of this branch's fix commits.
The user it serves — someone hand-tuning filament_flow_ratio — is a
--set user already, and `slice --list-settings` is the honest way to show
them the vocabulary.

Kept: the 25 named fields (a closed set, the CLI's own flags, no
inference) and the KEY=VALUE escape hatch, now a plain key + bucket +
value editor. The bucket is the user's choice, exactly as --set vs
--set-filament is on the command line, and it resets to process for each
new key so a filament choice never carries over into the next override —
the silent-no-op this split exists to prevent, in both directions.

Also drops the plumbing that existed only to feed the inference:
slicer.options.setting_value_domains/_setting_value_domain, and the
profiles_dir the prepare screen read purely to hand to the browser.
setting_catalog stays; it is what --list-settings reads.

Net -829 lines: source -366, tests -463, docs unchanged. Full suite green
(1303 passed), coverage 88.4%, and tui/ coverage improved — settings.py
98.4%, every tui/ module at or above 95.8%.
The manual had a section literally titled "`plate go` or `plate tui`?",
and the tui help string ("dashboard, guided print, job monitor") sold the
TUI as a replacement for the wizard. Between them they presented two
products and made picking one the reader's problem.

There is one interactive front door. `plate` walks you through a print;
with the optional extra, `plate tui` additionally gives you a live view of
the printer. Both drive the same pipeline, so nothing is reachable from
one and not the other, and which you get is a question of what your
terminal can do — not a decision to research.

So: the "or?" section becomes "Where it runs", stating the fact (Textual
is never a runtime dependency, so the wizard keeps working on dumb
terminals, slow SSH links and with screen readers) instead of posing the
choice. The tui help and command docstring lead with what the TUI is
uniquely for — the live view — rather than restating what `go` does.

Also drops a stale README claim: it still advertised the advanced-settings
screen as "every setting in your installed profiles, searchable", which
described the browser removed in the previous commit.

Docs and help strings only; no behaviour change.
@DLANSAMA
DLANSAMA marked this pull request as ready for review August 5, 2026 12:28
@DLANSAMA
DLANSAMA merged commit 3d51016 into main Aug 5, 2026
6 checks passed
@DLANSAMA
DLANSAMA deleted the feat/tui branch August 5, 2026 12:28
DLANSAMA added a commit that referenced this pull request Aug 5, 2026
docs/schemas/tui.json arrived on main with the TUI. The drift gate did exactly
what it was built to do and failed with "no contract generates: tui.json" —
the check works in both directions, so a published schema with no model behind
it is a failure, not a silent pass.

Same shape as Go: both are human-only front-ends with no machine contract, so
the only payload either emits is the --json refusal. Verified the generated
schema loses nothing from the hand-written one.
DLANSAMA added a commit that referenced this pull request Aug 5, 2026
docs/schemas/tui.json arrived on main with the TUI. The drift gate did exactly
what it was built to do and failed with "no contract generates: tui.json" —
the check works in both directions, so a published schema with no model behind
it is a failure, not a silent pass.

Same shape as Go: both are human-only front-ends with no machine contract, so
the only payload either emits is the --json refusal. Verified the generated
schema loses nothing from the hand-written one.
DLANSAMA added a commit that referenced this pull request Aug 5, 2026
…writing them (#101)

* refactor: generate docs/schemas from typed contracts instead of hand-writing them

The 25 files in docs/schemas were hand-maintained, which meant nothing stopped
them drifting from what the commands actually emit. They are now generated from
frozen dataclasses in bambu_cli/contracts/, and CI regenerates and diffs, so
drift is a build failure.

  bambu_cli/contracts/base.py     Contract base + spec() field constraints
  bambu_cli/contracts/models.py   25 published contracts + 8 nested structures
  scripts/gen_schemas.py          the generator (--check is the CI gate)

Fidelity was verified before overwriting anything: the generator was diffed
against the committed schemas until it produced ZERO losses — every constraint
the hand-written files expressed (minLength, minimum, nested required, field
descriptions, the whole status.printer shape) is reproduced. The 78 differences
that remain are all strictly more precise: types alongside consts, item types
on arrays, and explicit nullability.

Two real defects surfaced while doing it, both previously invisible:

  - download.json required 7 fields; a naive model made 4 of them optional.
    Caught by the loss diff, fixed with spec(required=True).
  - error_envelope typed next_command as `{}` (anything), which hid that
    job/send emits it as null. The generated schema is explicit, and the
    contract test failed until the model said so.

Pydantic is a DEV dependency only. It derives JSON Schema from the dataclass
annotations at build time and is never imported at runtime — verified by a test
that subprocess-imports the package and asserts pydantic is absent from
sys.modules, and visible in uv.lock: runtime deps stay at 3, with pydantic under
the test extra behind a python_version >= '3.10' marker. Serialization stays in
emit_json because that pass applies the credential redaction a model_dump_json()
would bypass; emit_json/emit_json_line now accept a contract or a plain dict.

The contracts annotate optionals as `X | None`, which only evaluates on 3.10+.
Nothing at runtime resolves them (dataclasses keeps annotations as strings), so
the 3.9 floor is unaffected — asserted by a test, and the generator refuses to
run below 3.10 with an explanatory message rather than failing obscurely.

Wired so far: version, light, pause, resume, stop. The remaining commands still
emit dicts; every one of them is validated against its generated schema by
tests/contracts/, so the contract holds either way. Converting the rest --
especially job's incrementally-built summary -- is follow-up work.

Docs: schema counts and coverage refreshed to measured values (1215 collected /
1214 passing, 87.0% Linux) rather than left stale.

Gates: 1214 passed (was 1177), coverage 87.0% (was 86.5%), ruff/ruff-format/
mypy/bandit/layers/drift-check/syntax/help/workflow/compat/package smokes green.
Verified on a real CPython 3.9.25 that contracts import, payloads render, and
pydantic stays absent.

* feat: add the Tui output contract now that #97 has landed

docs/schemas/tui.json arrived on main with the TUI. The drift gate did exactly
what it was built to do and failed with "no contract generates: tui.json" —
the check works in both directions, so a published schema with no model behind
it is a failure, not a silent pass.

Same shape as Go: both are human-only front-ends with no machine contract, so
the only payload either emits is the --json refusal. Verified the generated
schema loses nothing from the hand-written one.

* docs: record the measured numbers at this point in the stack

1374 collected / 1373 passing, 89.0% Linux — measured on this branch after the
rebase onto main-with-TUI, not inherited from either side of the doc conflict.

These were placeholders until measured: each stacked PR has to stand up green on
its own, and test_docs_consistency rejects a placeholder exactly as it rejects a
stale number. PR #102 re-measures on top.

---------

Co-authored-by: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com>
DLANSAMA added a commit that referenced this pull request Aug 5, 2026
…#107)

* docs: 0.5.0 truth pass — correct what the docs now get wrong

Audits every tracked doc against the 0.5.0 code and the release commit's own
CI run (31044588411 on 5b08720), and fixes what had gone false or stale.

Actively false, now corrected:
- CONTRIBUTING claimed the four 2026-07 audit gaps were open. All four had
  closed: B.4 cli helper extraction, B.5 single TLS pin helper, the remaining
  JSON schemas (generated now), and the camera bind/pin hardenings. It also
  cited ~82% coverage against a measured 89.
- quality-roadmap said "Phase D schemas largely landed but not complete for
  every command" while docs/api.md and a contract test say the opposite and
  enforce it. Schemas are complete and generated.
- quality-roadmap still framed the TUI as unmerged work on feat/tui that "has
  not reached CI yet". It shipped in 0.5.0 (#97 + #104).
- test-backlog put the mutation floor at 40 (it is 48) and asked for a mutmut
  re-run on slicer/output.py that already happened on 2026-08-04 and disproved
  the prediction it was based on.
- manual's OrcaSlicer detection table listed only the legacy Flatpak app id
  and no Flatpak profile paths at all, contradicting the prose two paragraphs
  above and the real candidate list in config.py.
- docs/plans/interactive-mode-plan.md still read "Draft for implementation"
  and told the reader not to start until 0.4.0 was tagged; plate go shipped in
  0.4.0. Marked implemented, historical text kept as written.

Stale or incomplete:
- Measured numbers refreshed with their source and date: 1419 passing / 1420
  collected, 89.1% branch coverage over 8120 statements on local Linux, and
  the full CI matrix (Windows 88.8% binding, macOS 89.1%, Linux 89.2-89.3%).
  Local and CI figures are cited separately, not reconciled.
- tui/ coverage row was one module behind (#104 added widgets/summary.py):
  now 14 of 18 at 100%, package minimum 95.8%.
- AGENTS module table was missing bambu.py and printer.py; its repo-only list
  was missing docs/releasing.md, docs/README.md and docs/plans/*.
- AGENTS and CONTRIBUTING gate lists omitted pip-audit, gen_schemas --check
  and check_layers, all blocking in the same CI job.
- releasing.md's dev-bump example still said 0.5.0.dev0.
- README told pipx / uv tool users to `pip install 'platecli[tui]'`, which
  installs into the wrong environment; and its agent section did not mention
  that go and tui refuse --json.

No grade was inflated: the scoreboard stays A- / A, coverage 89 against a
target of 92, per-module floors still unenforced, camera residuals still open.

* fix: plate tui — stop the scrollbar clipping which material was detected

On a terminal short enough that the prepare form overflows, #prepare-inputs
grows a vertical scrollbar, and a Textual scrollbar takes real cells rather
than overlaying. The longest radio label is the AMS-detected material —
"PLA — easy, rigid, most models  (detected in AMS)", 49 cells, plus 4 for the
toggle and its padding = 53 — against a column that offered 54. One cell of
slack, which the scrollbar consumed: the label rendered as "(detected in AMS"
with the closing paren shaved off, so the screen silently truncated the one
thing that tells you which filament it found.

Widening the column to 62 leaves 54 cells even while scrolling. Found by
recording the TUI and looking at the frames — every existing assertion passed,
because the label *string* was intact and only its rendering was cut. The new
test asserts the RadioSet has room for its widest button while the scrollbar is
up, and fails when the column goes back to 60.

* chore: untrack the raw hero capture, and ignore raw captures going forward

docs/job-hero.mp4 is the full-length VHS capture of the hero run — 2.3 MB, most
of it the dead air while OrcaSlicer works. The postable cut that scripts/
trim_hero.sh produces from it (docs/job-hero-post.mp4, 427 KB) is already
committed and is what the launch material uses, so the raw file was pure repo
weight and no doc referenced it.

It was never meant to be tracked. It was untracked AND unignored, which the
post-audit gameplan explicitly called out as a trap, and it duly got swept into
#102 — a test-decomposition PR — by a `git add`. Ignoring it closes the trap.

The file stays on disk for re-cutting the video without re-running a real print;
it stays in history, which is fine: frames were extracted and checked and it
leaks no IP, serial, or home path. This only removes it from HEAD.

* docs: record the TUI against a real print, and put each GIF under what it shows

The 0.5.0 headline feature had no imagery at all, and the picture below its
README section was the `plate doctor` GIF — health-check output captioned as if
it illustrated the live dashboard. Meanwhile `plate doctor` is introduced 40
lines earlier with nothing beside it. Both are now under what they actually
show: doctor beside the `plate doctor` line, the new capture under "Watch the
printer while it works".

docs/tui.gif is a real printer mid-print, not a mockup and not --sim: the
dashboard at 13% with nozzle 219.9->220C, bed 60->60C and layer 4/240, then the
job monitor (including its "the print keeps going; nothing here can stop it"
line), then the two-column prepare screen. Frames were extracted and read
before committing — no IP, serial, access code or home path in any of them,
and the shell prompt is bare.

docs/tui.tape records how, and carries the two things that cost time here:
vhs Width/Height are PIXELS, not cells, so the obvious 1100px window is only
~93 columns and silently captures the NARROW single-column prepare layout
rather than the two-column one; and an idle printer records as a static 0%,
so the print has to be running before the tape is rolled.

---------

Co-authored-by: DLANSAMA <258674612+DLANSAMA@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant