Skip to content

TUI correctness pass — 14 bug fixes (T1–T14)#609

Merged
ramseymcgrath merged 18 commits into
mainfrom
chore/oss-hygiene-and-claude-setup
May 18, 2026
Merged

TUI correctness pass — 14 bug fixes (T1–T14)#609
ramseymcgrath merged 18 commits into
mainfrom
chore/oss-hygiene-and-claude-setup

Conversation

@ramseymcgrath
Copy link
Copy Markdown
Collaborator

Summary

Implements the full 14-task plan in docs/plans/tui-correctness-pass.md. One commit per task, each with a regression test under tests/tui/. Scope is bug fixes only — no refactors, no dead-code deletion (deferred to a separate pass).

Task Fix
T1 BuildOrchestrator._validate_environment honors local_build via stored config instead of a no-op widget walk
T2 handle_build_start aborts the build when _validate_donor_module() returns False
T3 Device row selection compares event.row_key.value instead of the wrapper
T4 ProfileManagerDialog implements _load/_delete/_export/_import/_create_new_profile (button presses no longer silently raise)
T5 Background monitor pushes through app_state.set_devices(...) and uses a signature-based change detector; new BaseDialog extracted, dialog CSS scoped to classes
T6 AppState.update_state passes shallow copies of old/new state to subscribers; subscriber list snapshotted before iteration
T7 App.notify override renamed to log_notification; Textual's notify left intact for toasts; 74 callsites migrated
T8 Donor template success notification fires once (coordinator owns it; main's duplicate removed)
T9 _run_shell drains stdout+stderr concurrently with a READLINE_TIMEOUT_SEC=1.0 poll, honors _should_cancel, terminate→kill on cancel
T10 ThreadPoolExecutor is build-scoped; _update_resource_usage is a graceful no-op when the pool is gone
T11 Vivado/container build args assembled as list[str] and shlex.join'd in _run_shell so spaced paths survive
T12 ConfigurationDialog renders Checkbox for bools, JSON-encodes dict/list fields, parses numerics with error notify; main surfaces from_dict errors
T13 set_timestamps validator only fills last_used when missing; new mark_used() is the explicit usage stamp; validate_config is read-only
T14 VirtualDeviceTable viewport-shift cursor restore tracks the absolute index into virtual_rows; clamps to 0 when scrolled out

Test plan

  • pytest tests/tui -q — 81/81 pass (was 36 before this branch; +45 new regression tests)
  • Manual smoke: launch the TUI, select a device, open Configuration / Profiles / Search dialogs, press Escape on each (BaseDialog binding), trigger a build, hit Stop mid-run (T9 cancel path).
  • Confirm local_build=True config now takes the local path on a non-root host (T1).
  • Confirm reading a profile no longer rewrites its last_used timestamp (stat profiles/<name>.json before/after).

…pp_state (T5)

Dialog hardening:
- New BaseDialog(ModalScreen[T]) in src/tui/dialogs/base.py owning a single
  priority escape binding and a dismiss() default. All eight dialogs
  (build_log, configuration, confirmation, device_details, file_path_input,
  help_dialog, profile_manager, search_filter) now subclass it.
- Migrate dialog-title and dialog-buttons from IDs (#) to classes (.) in
  main.tcss and every dialog template. Reusing the same DOM id across a
  modal screen stack was the source of intermittent style/binding bleed.
- ConfirmationDialog dismisses False on escape; HelpDialog dismisses True;
  SearchFilterDialog drops the redundant Cancel button (escape covers it).

T5 — background device monitor:
- BackgroundMonitor._monitor_device_changes now pushes through
  app_state.set_devices(...) instead of writing app._devices (which the
  reactive/filter chain does not observe). Switches to a signature-based
  change detector so we drop the dependency on the obsolete
  device_manager.check_for_changes() path.
- New tests/tui/test_background_monitor.py covers the push-through and the
  no-op-on-unchanged-signature paths.

Other small fixes piggybacking on the same surface:
- ui_coordinator clears stale search_text filter when the input is empty.
- main.py subscribes to AppState before installing action handlers so any
  early state mutation is observed.
- VirtualDeviceTable shows an explicit empty-state row when scan returns no
  devices.

All 38 tests in tests/tui pass.
…nt (T1)

_get_app() walked .parent from a bound progress_callback method, which has
no .app attribute — so it always returned None and every build took the
container path. Stop walking the widget tree: store self._config = config
at the top of start_build and have _validate_environment read it directly.
_get_app is the only caller; remove it entirely.

Regression test in tests/tui/test_build_orchestrator_local_build.py covers
both the local-build and container-build paths plus verifies _get_app is
gone.
handle_build_start awaited _validate_donor_module() but discarded the
return value. A False return (user dismissed the install confirmation)
let the build proceed anyway. Branch on the value: emit a 'Build cancelled'
notice and return early.

Regression test in tests/tui/test_handle_build_start_donor_cancel.py
covers both branches.
on_data_table_row_selected did `device.bdf == event.row_key`. The
underlying `row_key` is Textual's RowKey wrapper; older versions don't
implement __eq__ against plain str, so selected_device stayed None and
Start Build never enabled. Use `str(row_key.value)` (falling back to the
RowKey itself for forward-compat), keeping the comparison stable across
Textual versions.

Regression test in tests/tui/test_row_selection.py models the old-Textual
RowKey behaviour (no str-equality) to lock in the fix.
The five button handlers awaited _load_/_delete_/_export_/_import_/_create_new_profile
methods that didn't exist on the dialog — every press raised AttributeError,
swallowed silently. Implement them in-dialog using config_manager (already
injected) and the simplest existing primitives:
  - Load: dismiss(name) so the caller in main.py applies the profile
  - Delete: ConfirmationDialog -> config_manager.delete_profile -> refresh
  - Export: FilePathInputDialog -> config_manager.export_profile(name, path)
  - Import: FilePathInputDialog -> config_manager.import_profile(path) -> refresh
  - Create New: FilePathInputDialog for the name -> save_profile(name, current)

All five wrap config_manager calls in try/except and surface failures via
self.app.notify(severity='error'). FilePathInputDialog is reused for the
name prompt — marked with a TODO(tui-pass-2) to swap in a dedicated
name-input primitive in the cleanup pass.

Regression tests in tests/tui/test_profile_manager_dialog.py cover each
method's happy path, cancel path, and one failure path.
update_state previously passed the live self._state to every subscriber.
Any subscriber that mutated the second arg or triggered a re-entrant
update_state corrupted the iteration. Pass a shallow copy of both old and
new state, document the read-only contract, and snapshot the subscriber
list so a callback that unsubscribes itself doesn't skip the next one.

Regression test in tests/tui/test_app_state_subscribe.py covers both the
mutate-snapshot path and the re-entrant update path.
The persistent-log override in PCILeechTUI.notify shadowed Textual's
App.notify, so toasts never appeared and unknown-severity callers were
silently dropped. Rename the override to log_notification and stop
shadowing — Textual's notify is now reachable for ephemeral toasts.

All in-scope persistent-log callsites updated to log_notification (deferred
files left alone per the pass scope):

  src/tui/main.py — 36 callsites
  src/tui/core/ui_coordinator.py — 27 callsites
  src/tui/core/error_handler.py — 2 callsites
  src/tui/utils/graceful_degradation.py — 1 callsite
  src/tui/dialogs/profile_manager.py — 2 callsites (in helpers + 1 in _refresh)
  src/tui/dialogs/build_log.py — 5 callsites
  src/tui/dialogs/device_details.py — 1 callsite

Out of scope (dead modules per the pass scope): src/tui/core/device_operations.py,
src/tui/core/build_operations.py, src/tui/commands/.

Severity values are unchanged at callsites; the log line is just a tag.
Textual's notify is the canonical surface for toasts and accepts only
'information' / 'warning' / 'error' — new toast callers should use those.

Tests in tests/tui/test_log_notification.py cover the rich-log write, the
de-overridden notify, and the swallowed-error path.
…tor (T8)

generate_donor_template (coordinator) and _generate_donor_template (main)
both emitted the same '✓ Donor info template saved to: ...' line. Drop
the duplicate from main.py; keep the unique informational hint about how
to use the file. The error branch was dead too (coordinator already
handles its own exceptions), so it's gone.

Regression test in tests/tui/test_donor_template_notify_once.py asserts
no 'saved' message originates from main and that the hint fires exactly
once on success.
…ll (T9)

Old monitored path: single stdout readline() loop with no stderr drain, no
asyncio.wait_for timeout, no cancel poll inside the loop. A grandchild
keeping stdout open (Vivado is the typical offender) blocks readline
forever — the Stop button is inert during synthesis.

New path:
- Drain stdout and stderr concurrently as asyncio tasks; stderr is
  captured into a buffer for error reporting on failure.
- Each readline is wrapped in asyncio.wait_for(timeout=READLINE_TIMEOUT_SEC)
  so the drainer wakes regularly enough to observe cancel/exit.
- Main loop polls _should_cancel between process.wait() timeouts; on
  cancel it terminate()s, waits 2s, then kill()s if still alive.
- READLINE_TIMEOUT_SEC = 1.0 module-level constant.

Regression tests in tests/tui/test_run_shell_cancel.py:
- assert the constant lives at module scope
- spawn 'sleep 30', trip _should_cancel, assert it dies in <4s
- spawn a process that closes stdout but lingers, assert no hang
The orchestrator held a single ThreadPoolExecutor for its whole lifetime;
start_build's finally called shutdown(wait=True). A subsequent
_update_resource_usage tick on the dead pool raised RuntimeError, and a
second start_build on the same orchestrator was permanently broken.

- self._executor: Optional[ThreadPoolExecutor] = None at rest
- create in start_build right after the cancel/config setup
- finally: nullify the attribute then shutdown the local handle
- _update_resource_usage early-return when executor is None (late ticks
  after shutdown are now graceful no-ops)

Note: the plan called out cancelling a 'resource monitor task' before
shutdown — there isn't one in this codebase; _update_resource_usage is
invoked inline from _update_progress, so the no-op guard is sufficient.

Regression tests in tests/tui/test_executor_lifecycle.py cover the rest
state, the late-tick no-op, and two sequential builds on one orchestrator.
… _run_shell (T11)

Old assembly built a list of multi-word strings, joined them with ' ', then
.split() before passing to _run_shell. Paths with spaces got shredded — a
donor-info-file like '/tmp/foo bar/donor.json' became three argv entries.

- Build args as proper list[str] from the start (one token per element).
- Local path: ['python3', 'src/build.py', *build_args]
- Container path: podman args + ['python3', '/app/src/build.py', *build_args]
- _run_shell now uses shlex.join when given a list, so spaced tokens
  survive create_subprocess_shell's reparse.

Regression test in tests/tui/test_vivado_cmd_quoting.py round-trips a
spaced --donor-info-file through shlex.join/split and asserts _run_shell
passes the same shell string.
…rors (T12)

Every field had been rendered as Input(str(value)); on save, the dialog
returned raw strings. Dict/list fields came back as repr ('{}', '[]'),
BuildConfiguration.from_dict failed silently in main's outer try/except,
and edits dropped on the floor.

- Bool fields render as Checkbox (returns native bool).
- Dict/list fields (custom_parameters, feature_flags, compatibility_overrides
  plus any value that is dict/list at compose time) render as JSON in an
  Input and parse with json.loads on save.
- Int/float fields parse with their constructor; ValueError surfaces via
  app.log_notification(severity='error') and the dialog stays open so the
  user can fix the value (no silent dismiss).
- main._open_configuration_dialog no longer silently swallows from_dict
  errors — they're reported via log_notification, live config untouched.

Regression tests in tests/tui/test_configuration_dialog_types.py cover
dict/list round-trip, bool preservation, numeric parse, invalid-number
error path, and cancel.
set_timestamps was a @model_validator(mode='before') that unconditionally
rewrote last_used = now() on every BuildConfiguration construction. Every
read-path that round-tripped through the model (validate_config probe,
load_profile pre-stamp, save_profile schema check) clobbered the field.

- set_timestamps now only fills last_used when missing or None.
- New BuildConfiguration.mark_used() records an explicit usage timestamp.
- load_profile calls mark_used() exactly once on the real load path
  before save_profile persists. validate_config still doesn't save and
  no longer mutates last_used as a side-effect.

Regression tests in tests/tui/test_last_used_preserved.py:
  - existing last_used round-trips unchanged
  - missing last_used is populated once and stable across rebuilds
  - mark_used updates the field
  - validate_config does not touch the on-disk file's mtime
DataTable.rows is an OrderedDict[RowKey, Row], not an ordered sequence.
The old restore loop iterated it as a list and did row[0] == current_key,
which subscripts a RowKey object — after a viewport shift, cursor landed
on the wrong row (or row 0 by chance).

Track the *absolute index* of the selected device into virtual_rows
before the shift. After re-render, compute on-screen = absolute -
new_visible_start. If the device scrolled out of the viewport, place
the cursor at row 0. Use move_cursor(row=...) (falls back to direct
cursor_row assignment for older Textual versions).

Regression tests in tests/tui/test_virtual_device_table_cursor.py cover
both the in-viewport-follow case and the out-of-viewport clamp case
using Textual's run_test harness.
Copilot AI review requested due to automatic review settings May 18, 2026 04:56
Comment thread src/tui/dialogs/configuration.py Fixed
Comment thread src/tui/dialogs/profile_manager.py Fixed
Comment thread src/tui/dialogs/profile_manager.py Fixed
Comment thread src/tui/widgets/virtual_device_table.py Fixed
Comment thread src/tui/widgets/virtual_device_table.py Fixed
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR applies a broad TUI correctness pass covering build orchestration, dialog behavior, notification routing, state updates, profile handling, and regression coverage.

Changes:

  • Fixes TUI state/selection/update paths, notification logging, and dialog escape/button behavior.
  • Updates build orchestration for local builds, cancellation, executor lifecycle, and shell argument quoting.
  • Adds focused regression tests for the reported T1–T14 correctness fixes.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/tui/core/app_state.py Sends copied state snapshots to subscribers.
src/tui/core/background_monitor.py Updates device refresh flow through app state.
src/tui/core/build_orchestrator.py Fixes local build validation, shell quoting, cancellation, and executor lifecycle.
src/tui/core/config_manager.py Explicitly stamps profiles on load-to-use.
src/tui/core/error_handler.py Routes errors to persistent notification logging.
src/tui/core/ui_coordinator.py Updates notifications and donor validation cancellation behavior.
src/tui/dialogs/base.py Adds shared modal dialog base with Escape dismiss.
src/tui/dialogs/build_log.py Uses shared dialog styling/base and persistent notifications.
src/tui/dialogs/configuration.py Adds typed config editing and validation feedback.
src/tui/dialogs/confirmation.py Uses shared dialog base and Escape cancel semantics.
src/tui/dialogs/device_details.py Uses shared dialog base/styling and log notifications.
src/tui/dialogs/file_path_input.py Uses shared dialog base/styling.
src/tui/dialogs/help_dialog.py Uses shared dialog base and Escape close behavior.
src/tui/dialogs/profile_manager.py Implements profile load/delete/export/import/create actions.
src/tui/dialogs/search_filter.py Uses shared dialog base and scoped dialog styling.
src/tui/main.py Renames persistent notification method and fixes selection/config/template flows.
src/tui/models/configuration.py Preserves last_used unless explicitly marked used.
src/tui/styles/main.tcss Scopes dialog styling by class instead of IDs.
src/tui/utils/graceful_degradation.py Routes feature notifications to persistent log.
src/tui/widgets/virtual_device_table.py Adds empty state and fixes viewport cursor restoration.
tests/tui/test_app_state_subscribe.py Adds state subscriber regression tests.
tests/tui/test_background_monitor.py Adds background monitor regression tests.
tests/tui/test_build_orchestrator_local_build.py Adds local/container validation tests.
tests/tui/test_configuration_dialog_types.py Adds typed configuration dialog tests.
tests/tui/test_dialogs_base.py Adds shared dialog Escape behavior tests.
tests/tui/test_donor_template_notify_once.py Adds donor template notification dedupe tests.
tests/tui/test_executor_lifecycle.py Adds executor lifecycle tests.
tests/tui/test_handle_build_start_donor_cancel.py Adds donor validation cancellation tests.
tests/tui/test_last_used_preserved.py Adds timestamp preservation tests.
tests/tui/test_log_notification.py Adds notification rename behavior tests.
tests/tui/test_profile_manager_dialog.py Adds profile manager action tests.
tests/tui/test_row_selection.py Adds row key selection tests.
tests/tui/test_run_shell_cancel.py Adds shell cancellation/readline tests.
tests/tui/test_ui_coordinator.py Updates test app notification stub.
tests/tui/test_virtual_device_table_cursor.py Adds viewport cursor regression tests.
tests/tui/test_vivado_cmd_quoting.py Adds shell quoting regression tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +126 to +130
signature = tuple(
(d.bdf, d.driver, d.is_suitable) for d in devices
)

if has_changes:
# Only update if devices have changed
devices = await self.app.device_manager.get_devices()
if signature != self._last_status.get("devices_signature"):
Comment thread src/tui/main.py
Comment on lines +632 to +633
fire. For ephemeral toasts (Textual's `Notification`), call
`self.log_notification(...)` directly — that's now Textual's built-in.
Comment thread src/tui/dialogs/configuration.py Outdated

Bool fields render as a Checkbox; dict/list fields render as JSON in an
Input and parse with json.loads on save; int/float fields parse with
their constructor and surface errors via app.notify instead of silently
Comment on lines +166 to +169
try:
ok = self.config_manager.export_profile(name, Path(path_str))
if ok:
self._notify_info(f"Exported profile '{name}' to {path_str}")
Comment on lines +57 to +60
cmd = (
'python3 -c "import os,sys,time; '
'print(\'hello\'); sys.stdout.close(); os.close(1); time.sleep(0.5)"'
)
 review)

After T13, export_profile -> load_profile -> mark_used + save mutated the
source profile's timestamp on every export. Extract _load_profile_raw
(no stamping, no save-back); export_profile uses that. load_profile
still does the stamp + save on the real load-to-use path.

Regression test: export a profile and assert its source mtime and content
are unchanged. Caught by Copilot in PR #609 review.
…ields (PR #609 review)

The (bdf, driver, is_suitable) signature missed updates to vendor/device
name, IOMMU group, suitability score, and status indicator — all of which
VirtualDeviceTable._add_device_row renders. A rescan that changed only
those would silently leave stale UI. Include them in the signature.

New test asserts a vendor-name-only change still triggers set_devices.
Caught by Copilot in PR #609 review.
…review)

sys.stdout.close() in the test child already closes fd 1; the trailing
os.close(1) raised OSError so the subprocess exited non-zero, meaning the
'closes stdout but keeps running' test was passing for the wrong reason.
Remove the second close. Caught by Copilot in PR #609 review.
…string (PR #609 review)

CodeQL flagged five non-fatal exception blocks as unexplained. They are
intentional (best-effort UI notification, decorative empty-state row,
Textual-version-fallback cursor restore) — add a one-line rationale to
each. Also update ConfigurationDialog docstring to reference
log_notification (post-T7 rename) instead of the old app.notify.
@ramseymcgrath ramseymcgrath merged commit d11bcd3 into main May 18, 2026
23 of 30 checks passed
@ramseymcgrath ramseymcgrath deleted the chore/oss-hygiene-and-claude-setup branch May 18, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants