diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-20 12:12:22 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-20 12:12:22 +0200 |
| commit | 6bff3d035d9c1bccacb3fff39d8ef92e9f839748 (patch) | |
| tree | 5f61ebd641d578cc6a7b856e3ef81ee8c24e9fd3 /docs | |
| parent | f4ee0cee59cd5792af167a1def4515d8120bf477 (diff) | |
| download | sb-builder-6bff3d035d9c1bccacb3fff39d8ef92e9f839748.tar.gz sb-builder-6bff3d035d9c1bccacb3fff39d8ef92e9f839748.zip | |
Add UI frame implementation plan
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-10-20-sb-builder-ui.md | 1651 |
1 files changed, 1651 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-10-20-sb-builder-ui.md b/docs/superpowers/plans/2026-10-20-sb-builder-ui.md new file mode 100644 index 0000000..4e80b90 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-sb-builder-ui.md @@ -0,0 +1,1651 @@ +# sb-builder: one UI frame, implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Every tab in one frame (permanent tabs, a main ribbon of actions, a small ribbon of view controls), actions on ticked rows, and each job's next step as a dialog where the user is. + +**Architecture:** `sb_builder/ui.py` builds ribbon `QAction`s (`action`, `enable`); every tab exposes `actions` and `view_actions` and the main window shows the current tab's lists in two toolbars. `JobsTab` gains `NextStep` (preview log, Run it / Apply shadowing) and the `started`/`preview_requested` signals; the main window keeps the ids it started (`mine`) and opens next steps only for them. The helper's `hint-new` takes several packages. + +**Tech Stack:** Python 3.11+ stdlib, PyQt6. + +Spec: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`. + +Every code step was run before this plan was written: the diffs apply to +`master` at `f4ee0ce` and the suite ends at 115 tests, OK. Screens were +checked offscreen (each tab's ribbons, filter rows not stretched, the Jobs +tab reading "Jobs (running)"). + +--- + +### Task 1: helper, `hint-new` with several packages + +**Files:** `remote/sbb-remote`, `sb_builder/__init__.py`, `tests/test_remote.py` + +- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u1_test.diff`, then `git apply /tmp/u1_test.diff`. + +```diff +diff --git c/tests/test_remote.py i/tests/test_remote.py +index 14622b8..54c5614 100644 +--- c/tests/test_remote.py ++++ i/tests/test_remote.py +@@ -331,7 +331,8 @@ class HintJobsTest(Base): + me = [sys.executable, os.path.abspath(sbb.__file__), "_hint"] + self.assertEqual(sbb.KINDS["hint-update"](self.cfg, ["x=2.0", "--no-dl"]), + (me + ["hint-update", "x=2.0", "--no-dl"], {})) +- self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "2.0"]), (me + ["hint-new", "x", "2.0"], {})) ++ self.make_pkg("misc/y", self.info) ++ self.assertEqual(sbb.KINDS["hint-new"](self.cfg, ["x", "y"]), (me + ["hint-new", "x", "y"], {})) + self.assertFalse(sbb.signs("hint-update", ["x=2.0"])) + + def test_kinds_reject(self): +@@ -351,14 +352,14 @@ class HintJobsTest(Base): + ("hint-update", ["neovim=1"], "bundled deps"), + ("hint-update", ["shadowed=1"], "not managed"), + ("hint-new", [], "hint-new <pkg>"), +- ("hint-new", ["x", "1", "2"], "hint-new <pkg>"), +- ("hint-new", ["neovim", "1"], "bundled deps")): ++ ("hint-new", ["x", "2.0"], "no such package: 2.0"), # no version any more ++ ("hint-new", ["x", "x"], "twice")): + with self.subTest(kind=kind, args=args), self.assertRaisesRegex(sbb.Error, why): + sbb.KINDS[kind](self.cfg, args) + sbb.KINDS["hint-new"](self.cfg, ["neovim"]) # no version: no bundled deps to touch + self.make_pkg("misc/y", self.info, hint="") +- with self.assertRaisesRegex(sbb.Error, "exists"): +- sbb.KINDS["hint-new"](self.cfg, ["y"]) ++ with self.assertRaisesRegex(sbb.Error, "y: hint exists"): ++ sbb.KINDS["hint-new"](self.cfg, ["x", "y"]) # one with a hint refuses the whole job + + def test_update_backs_up_and_reports(self): + self.make_pkg("misc/x", self.info, hint='DELREQUIRES="rust-opt"\n') +@@ -388,8 +389,9 @@ class HintJobsTest(Base): + + def test_new(self): + self.make_pkg("misc/x", self.info) +- rc, result, _ = self.run_hint("hint-new", "x") +- self.assertEqual((rc, result), (0, {"ok": ["x"], "failed": []})) ++ self.make_pkg("misc/y", self.info) ++ rc, result, _ = self.run_hint("hint-new", "x", "y") ++ self.assertEqual((rc, result), (0, {"ok": ["x", "y"], "failed": []})) + self.assertIn('#REQUIRES="rust-opt a"', self.read(self.hint)) + self.assertIn('MD5SUM="old"', self.read(self.hint)) # no version given: no download + +``` + +- [ ] **Step 2:** Run `python3 -m unittest tests.test_remote`. Expected: FAILED, `hint-new <pkg>` usage and `y: hint exists` assertions. +- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u1_code.diff`, then `git apply /tmp/u1_code.diff`. + +```diff +diff --git c/remote/sbb-remote i/remote/sbb-remote +index 2674ed6..5183b77 100755 +--- c/remote/sbb-remote ++++ i/remote/sbb-remote +@@ -40,7 +40,7 @@ import time + import tomllib + import urllib.request + +-VERSION = "0.5.0" ++VERSION = "0.6.0" + DEFAULTS = { + "state_dir": "/var/lib/sb-builder", + "repo_dir": "/var/lib/sbopkg/SBo-danix", +@@ -168,9 +168,9 @@ def hint_args(cfg, kind, args): + raise Error(f"{kind}: unknown option {o}") + rest = [a for a in args if a != "--no-dl"] + if kind == "hint-new": +- if not 1 <= len(rest) <= 2: +- raise Error("usage: hint-new <pkg> [<version>] [--no-dl]") +- pairs = [(rest[0], rest[1] if len(rest) == 2 else None)] ++ if not rest: ++ raise Error("usage: hint-new <pkg>... [--no-dl]") ++ pairs = [(pkg, None) for pkg in rest] # the .info pin; Update sets a version later + else: + if not rest: + raise Error("hint-update needs at least one pkg=version") +diff --git c/sb_builder/__init__.py i/sb_builder/__init__.py +index 21a93ab..f9bea08 100644 +--- c/sb_builder/__init__.py ++++ i/sb_builder/__init__.py +@@ -16,4 +16,4 @@ + + """sb-builder: Qt6 GUI driving the SlackBuild tooling on a build VM.""" + +-VERSION = "0.5.0" # must match remote/sbb-remote VERSION ++VERSION = "0.6.0" # must match remote/sbb-remote VERSION +``` + +- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK`. (`VersionTest` passes: both files say 0.6.0.) +- [ ] **Step 5: Commit.** + +```bash +git add remote/sbb-remote sb_builder/__init__.py tests/test_remote.py +git commit -m "sbb-remote 0.6.0: hint-new takes several packages, no version + +Each gets the .info pin; Update selected sets a version later. One +package that already has a hint refuses the whole job before it starts. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +### Task 2: the frame, ribbons, next-step dialogs + +**Files:** +- Create: `sb_builder/ui.py` +- Modify: `sb_builder/dashboard.py`, `sb_builder/packages.py`, `sb_builder/hints_tab.py`, `sb_builder/results.py`, `sb_builder/jobs.py`, `sb_builder/mainwindow.py` +- Test: `tests/test_gui.py` + +One task: the tabs' ribbon lists and the main window that shows them only +work together. Key points: tabs are created at startup, not closable, +`loaded` flags drive the first `reload()`; `_tab_changed` refills both +toolbars (a menu action gets `InstantPopup`); `_badge` sets the Jobs tab +icon and its "(running)" text (the tab style sheet would override a text +color); `show_next_step` keeps one `NextStep` per job, and for an automatic +shadowing preview shows it only when `loaded(has_plan)` says there is a plan. + +- [ ] **Step 1: Failing tests.** Save the diff below as `/tmp/u2_test.diff`, then `git apply /tmp/u2_test.diff`. + +```diff +diff --git c/tests/test_gui.py i/tests/test_gui.py +index 1f3f7b0..ee568c1 100644 +--- c/tests/test_gui.py ++++ i/tests/test_gui.py +@@ -29,7 +29,7 @@ import unittest # noqa: E402 + ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") + sys.path.insert(0, ROOT) + +-from PyQt6.QtCore import QCoreApplication, QEvent, QObject, QPointF, QProcess, Qt, pyqtSignal # noqa: E402 ++from PyQt6.QtCore import QObject, QPointF, QProcess, pyqtSignal # noqa: E402 + from PyQt6.QtGui import QColor # noqa: E402 + from PyQt6.QtWidgets import QApplication # noqa: E402 + +@@ -309,7 +309,7 @@ class SmokeTest(unittest.TestCase): + return w, remote + + def test_tabs_and_dashboard(self): +- w, _ = self.window() ++ w, remote = self.window(**{"job-start": ({"id": "20260101-000001-sbpull"}, None)}) + w.poll_jobs() + self.assertEqual(w.conn.text(), "● connected") + self.assertEqual(w.tabs.tabText(0), "Dashboard") +@@ -328,8 +328,10 @@ class SmokeTest(unittest.TestCase): + self.assertEqual(w.dashboard.legend.text(), "n/a") + jobs = w.open_jobs() + self.assertIs(w.open_jobs(), jobs) +- self.assertEqual(w.tabs.count(), 2) +- self.assertEqual(jobs.start_menu.actions()[0].text(), "sbpull personal (preview)") ++ self.assertEqual(w.tabs.count(), 5) ++ self.assertEqual([a.text() for a in jobs.pull_menu.actions()], ["personal (preview)"]) ++ jobs.pull_menu.actions()[0].trigger() ++ self.assertIn(("job-start", "sbpull", "personal", "--no"), remote.calls) + + def test_end_notification_once(self): + w, remote = self.window(jobs=({"jobs": [JOB]}, None)) +@@ -363,30 +365,6 @@ class SmokeTest(unittest.TestCase): + [("ok", 3), ("failed", 1), ("removed", 1)]) + self.assertEqual(w.dashboard.tiles["Built"].detail.text(), "of 10 · 6 never built") + +- def test_close_jobs_tab_while_following(self): +- # regression: the follow process ended after the tab was deleted, its +- # finished slot touched dead widgets and PyQt aborted the app +- w, remote = self.window(jobs=({"jobs": [JOB]}, None)) +- procs = QObject() +- self.addCleanup(procs.deleteLater) +- +- def follow(jid, offset): +- p = QProcess(procs) +- p.start("sleep", ["5"]) +- return p +- +- remote.follow = follow +- w.poll_jobs() +- jobs = w.open_jobs() +- jobs.select(JOB["id"]) +- self.assertIsNotNone(jobs.proc) +- w._close_tab(w.tabs.indexOf(jobs)) +- QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete.value) +- end = time.monotonic() + 1 +- while time.monotonic() < end: # let the killed process report in +- app.processEvents() +- self.assertIsNone(w.jobs_tab) +- + def test_packages_tab(self): + w, remote = self.window(**{"job-start": ({"id": "20260101-000001-slackrepo"}, None)}) + w.poll_jobs() +@@ -422,34 +400,107 @@ class SmokeTest(unittest.TestCase): + ["slackrepo", "build", "--preview", "curl", "nuclei"], + ["slackrepo", "update", "--preview"]]) + +- def test_run_preview_with_signing(self): +- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None), +- **{"gpg-status": ({"unlocked": False, "key": "K"}, None), +- "gpg-unlock": lambda args, stdin: ( +- (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)), +- "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) ++ def end_preview(self, job, **answers): ++ """A window where job, started by this session, goes from running to its status.""" ++ w, remote = self.window(jobs=({"jobs": [dict(job, status="running")]}, None), **answers) ++ w.mine.add(job["id"]) + w.poll_jobs() +- jobs = w.open_jobs() +- jobs.select(PREVIEW["id"]) +- self.assertTrue(jobs.run_btn.isVisibleTo(jobs)) ++ remote.answers["jobs"] = ({"jobs": [job]}, None) ++ w.poll_jobs() ++ return w, remote ++ ++ def test_preview_next_step_with_signing(self): ++ w, remote = self.end_preview( ++ PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None), ++ "gpg-unlock": lambda args, stdin: ( ++ (None, "bad passphrase") if stdin == b"wrong\n" else ({}, None)), ++ "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) ++ self.assertIs(w.tabs.currentWidget(), w.dashboard) # nobody was sent to Jobs ++ d = w.next_steps[PREVIEW["id"]] ++ self.assertEqual(d.windowTitle(), "Preview: slackrepo update nuclei") ++ self.assertTrue(d.publish.isChecked()) + answers = iter(["wrong", "right"]) +- jobs.ask_passphrase = lambda retry: next(answers) +- jobs.publish_box.setChecked(False) +- jobs.run_preview() ++ w.jobs_tab.ask_passphrase = lambda retry: next(answers) ++ d.publish.setChecked(False) ++ d.run_btn.click() + self.assertIn(("gpg-unlock", b"wrong\n"), remote.calls) + self.assertIn(("gpg-unlock", b"right\n"), remote.calls) +- self.assertEqual(remote.calls[-2], ("job-start", "slackrepo", "update", "--no-publish", "nuclei")) ++ self.assertIn(("job-start", "slackrepo", "update", "--no-publish", "nuclei"), remote.calls) ++ self.assertEqual(w.next_steps, {}) + +- def test_signing_cancel_starts_nothing(self): +- w, remote = self.window(jobs=({"jobs": [PREVIEW]}, None), +- **{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) +- w.poll_jobs() ++ def test_preview_not_now_then_run_it(self): ++ w, remote = self.end_preview(PREVIEW, **{"job-start": ({"id": "20260101-000001-slackrepo"}, None)}) ++ w.next_steps[PREVIEW["id"]].reject() ++ self.assertEqual(w.next_steps, {}) + jobs = w.open_jobs() + jobs.select(PREVIEW["id"]) +- jobs.ask_passphrase = lambda retry: None +- jobs.run_preview() ++ self.assertTrue(jobs.act_run.isEnabled()) ++ jobs.act_run.trigger() # Not now loses nothing ++ w.next_steps[PREVIEW["id"]].run_btn.click() ++ self.assertIn(("job-start", "slackrepo", "update", "nuclei"), remote.calls) ++ ++ def test_signing_cancel_starts_nothing(self): ++ w, remote = self.end_preview(PREVIEW, **{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) ++ w.jobs_tab.ask_passphrase = lambda retry: None ++ w.next_steps[PREVIEW["id"]].run_btn.click() + self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) + ++ def test_preview_from_elsewhere_has_no_dialog(self): ++ w, remote = self.window(jobs=({"jobs": [dict(PREVIEW, status="running")]}, None)) ++ w.poll_jobs() ++ remote.answers["jobs"] = ({"jobs": [PREVIEW]}, None) ++ w.poll_jobs() ++ self.assertEqual(w.next_steps, {}) ++ self.assertEqual(w.notifier.sent[0][0], "sb-builder: finished") ++ ++ def test_shadow_preview_dialog_only_with_a_plan(self): ++ shadow = dict(PREVIEW, id="20260101-000000-sbshadow", kind="sbshadow", args=["--no"]) ++ for log, shown in (("shadow: misc/foo\n", True), ++ ("sbshadow: nothing to shadow or unshadow.\n", False)): ++ with self.subTest(log=log): ++ procs = QObject() ++ self.addCleanup(procs.deleteLater) ++ ++ def follow(jid, offset, log=log): ++ p = QProcess(procs) ++ p.start("printf", ["%s", log]) ++ return p ++ w, remote = self.window(jobs=({"jobs": [dict(shadow, status="running")]}, None), ++ **{"job-start": ({"id": "20260101-000001-sbshadow"}, None)}) ++ remote.follow = follow ++ w.mine.add(shadow["id"]) ++ w.poll_jobs() ++ remote.answers["jobs"] = ({"jobs": [shadow]}, None) ++ w.poll_jobs() ++ d = w.next_steps.get(shadow["id"]) ++ end = time.monotonic() + 3 ++ while d and not d.isVisible() and shadow["id"] in w.next_steps and time.monotonic() < end: ++ app.processEvents() ++ self.assertEqual(shadow["id"] in w.next_steps and d.isVisible(), shown) ++ if shown: ++ self.assertEqual(d.run_btn.text(), "Apply shadowing") ++ self.assertFalse(d.publish.isVisibleTo(d)) ++ d.confirm = lambda q: True ++ d.run_btn.click() ++ self.assertIn(("job-start", "sbshadow", "--yes"), remote.calls) ++ ++ def test_jobs_tab_badge(self): ++ w, remote = self.window(jobs=({"jobs": [JOB]}, None)) ++ w.poll_jobs() ++ jobs_i = w.tabs.indexOf(w.jobs_tab) ++ self.assertEqual(w.badge, "running") ++ self.assertFalse(w.tabs.tabIcon(jobs_i).isNull()) ++ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs (running)") ++ remote.answers["jobs"] = ({"jobs": [dict(JOB, status="failed", end=5, exit=1)]}, None) ++ w.poll_jobs() ++ self.assertEqual(w.badge, "bad") # stays until Jobs is shown ++ self.assertEqual(w.tabs.tabText(jobs_i), "Jobs") ++ w.poll_jobs() ++ self.assertEqual(w.badge, "bad") ++ w.open_jobs() ++ self.assertIsNone(w.badge) ++ self.assertTrue(w.tabs.tabIcon(jobs_i).isNull()) ++ + def test_key_indicator_and_tile_links(self): + w, _ = self.window(**{"gpg-status": ({"unlocked": False, "key": "K"}, None)}) + w.poll_jobs() +@@ -459,16 +510,46 @@ class SmokeTest(unittest.TestCase): + w.dashboard.jobs.clicked.emit() + self.assertIs(w.tabs.currentWidget(), w.jobs_tab) + +- def test_ribbon_opens_tabs(self): +- w, _ = self.window() ++ def test_ribbons_per_tab(self): ++ w, remote = self.window() + w.poll_jobs() +- self.assertEqual([a.text() for a in w.ribbon.actions()], ++ self.assertEqual([w.tabs.tabText(i) for i in range(w.tabs.count())], + ["Dashboard", "Packages", "Hints", "Results", "Jobs"]) ++ self.assertFalse(w.tabs.tabsClosable()) ++ self.assertNotIn(("inventory",), remote.calls) # each tab loads on first show ++ expect = { ++ "Dashboard": (["Refresh"], []), ++ "Packages": (["Build", "Update", "Rebuild", "Remove", "Update all", "New hint", "Publish"], ++ ["Select visible", "Clear", "Refresh"]), ++ "Hints": (["Check upstream", "Update selected", "Delete hint"], ++ ["Select visible", "Clear", "No download", "Refresh"]), ++ "Results": (["Show build log"], ["Refresh"]), ++ "Jobs": (["Pull overlay", "Check shadowing", "Run it", "Kill"], []), ++ } ++ for i in range(w.tabs.count()): ++ w.tabs.setCurrentIndex(i) ++ main, view = expect[w.tabs.tabText(i)] ++ self.assertEqual([a.text() for a in w.ribbon.actions()], main) ++ self.assertEqual([a.text() for a in w.view_bar.actions()], view) ++ self.assertEqual(w.view_bar.isVisibleTo(w), bool(view)) ++ self.assertEqual(remote.calls.count(("inventory",)), 2) # Packages and Hints, once each ++ w.open_packages() ++ self.assertEqual(remote.calls.count(("inventory",)), 2) + self.assertEqual([m.text() for m in w.menuBar().actions()], ["&File"]) +- w.ribbon.actions()[1].trigger() +- self.assertIs(w.tabs.currentWidget(), w.packages_tab) +- w.ribbon.actions()[0].trigger() +- self.assertIs(w.tabs.currentWidget(), w.dashboard) ++ ++ def test_disabled_actions_say_why(self): ++ w, _ = self.window() ++ w.poll_jobs() ++ p, h, j = w.open_packages(), w.open_hints(), w.open_jobs() ++ for a, why in ((p.act_build["build"], "tick packages first"), ++ (p.act_new_hint, "none of the ticked packages is without a hint"), ++ (h.act_update, "tick outdated packages after Check upstream"), ++ (h.act_delete, "none of the ticked packages has a hint"), ++ (j.act_run, "select a finished preview"), (j.act_kill, "select a running job")): ++ with self.subTest(action=a.text()): ++ self.assertFalse(a.isEnabled()) ++ self.assertTrue(a.toolTip().endswith(why), a.toolTip()) ++ self.assertEqual(h.view_actions[0].toolTip(), "Select visible (Ctrl+A)") + + def test_hints_tab(self): + w, remote = self.window(**{"job-start": ({"id": "20260101-000001-hint-update"}, None), +@@ -534,6 +615,7 @@ class SmokeTest(unittest.TestCase): + None), + "hint-revert": ({"ok": True}, None), + "job-start": ({"id": "20260101-000001-slackrepo"}, None)}) ++ w.mine.add(job["id"]) + w.poll_jobs() + remote.answers["jobs"] = ({"jobs": [dict(job, status="failed", end=5, exit=1)]}, None) + w.poll_jobs() +@@ -561,17 +643,17 @@ class SmokeTest(unittest.TestCase): + w.poll_jobs() + tab = w.open_packages() + tab.table.setCurrentIndex(tab.proxy.index(1, 1)) # foo, has a hint +- self.assertFalse(tab.new_hint_btn.isVisibleTo(tab)) +- tab.open_hint_btn.click() ++ self.assertTrue(tab.open_hint_btn.isVisibleTo(tab)) ++ tab.open_hint_btn.linkActivated.emit("#") + self.assertIs(w.tabs.currentWidget(), w.hints_tab) +- tab.table.setCurrentIndex(tab.proxy.index(3, 1)) # curl, no hint +- self.assertTrue(tab.new_hint_btn.isVisibleTo(tab)) +- tab.ask_new_hint = lambda name: None +- tab.new_hint() +- self.assertFalse(any(c[0] == "job-start" for c in remote.calls)) +- tab.ask_new_hint = lambda name: ("8.0", True) +- tab.new_hint() +- self.assertIn(("job-start", "hint-new", "curl", "8.0", "--no-dl"), remote.calls) ++ tab.model.checked.update({"foo"}) ++ tab.update_count() ++ self.assertFalse(tab.act_new_hint.isEnabled()) # foo has one ++ tab.model.checked.update({"curl", "nuclei"}) ++ tab.update_count() ++ tab.act_new_hint.trigger() ++ self.assertIn(("job-start", "hint-new", "curl", "nuclei"), remote.calls) ++ self.assertIs(w.tabs.currentWidget(), w.hints_tab) # starting a job stays put + + def test_tab_shortcuts_wrap(self): + w, _ = self.window() +@@ -583,7 +665,7 @@ class SmokeTest(unittest.TestCase): + nxt.activated.emit() + self.assertEqual(w.tabs.currentIndex(), 0) # wrapped from Jobs to Dashboard + prev.activated.emit() +- self.assertEqual(w.tabs.currentIndex(), 2) ++ self.assertEqual(w.tabs.currentIndex(), 4) + + def test_results_tab(self): + w, remote = self.window() +@@ -597,10 +679,11 @@ class SmokeTest(unittest.TestCase): + tab.chips["failed"].setChecked(False) + self.assertEqual(tab.visible_paths(), ["games/pcsx2", "development/whisper.cpp", "misc/nuclei"]) + tab.table.selectRow(1) # whisper.cpp: no log dir +- self.assertFalse(tab.log_btn.isEnabled()) ++ self.assertFalse(tab.act_log.isEnabled()) ++ self.assertTrue(tab.act_log.toolTip().endswith("select a result with a log")) + tab.table.selectRow(0) +- self.assertTrue(tab.log_btn.isEnabled()) +- tab.show_log() ++ self.assertTrue(tab.act_log.isEnabled()) ++ tab.act_log.trigger() + self.assertIn(("build-log", "games/pcsx2"), remote.calls) + viewer = tab.viewers[-1] + self.assertIn("ERROR: make failed", viewer.text.toPlainText()) # ANSI stripped +``` + +- [ ] **Step 2:** Run `python3 -m unittest tests.test_gui`. Expected: FAILED, e.g. `AttributeError: 'MainWindow' object has no attribute 'mine'`. +- [ ] **Step 3: Implement.** Save the diff below as `/tmp/u2_code.diff`, then `git apply /tmp/u2_code.diff`. + +```diff +diff --git c/sb_builder/dashboard.py i/sb_builder/dashboard.py +index 5dee453..3096fb8 100644 +--- c/sb_builder/dashboard.py ++++ i/sb_builder/dashboard.py +@@ -22,10 +22,11 @@ import time + + from PyQt6.QtCore import QEvent, QPointF, QRectF, Qt, pyqtSignal + from PyQt6.QtGui import QColor, QFont, QPainter, QPalette +-from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QPushButton, +- QToolTip, QVBoxLayout, QWidget) ++from PyQt6.QtWidgets import (QFrame, QGridLayout, QHBoxLayout, QLabel, QToolTip, QVBoxLayout, ++ QWidget) + + from .fmt import ICON, NA, fmt_age, fmt_bytes, fmt_duration, fmt_time, job_title ++from .ui import SP, action + + # reserved status colors (dataviz reference palette); always shown with a text label + STATUS = {"ok": "#0ca30c", "aborted": "#fab219", "bad": "#ec835a", "failed": "#d03b3b", +@@ -288,11 +289,11 @@ class Dashboard(QWidget): + self.jobs.body.addStretch() + + self.stale = muted(QLabel()) +- self.refresh_btn = QPushButton("Refresh") ++ self.refresh = action(self, "Refresh", "view-refresh", SP.SP_BrowserReload) ++ self.actions, self.view_actions = [self.refresh], [] + top = QHBoxLayout() + top.addWidget(self.stale) + top.addStretch() +- top.addWidget(self.refresh_btn) + + grid = QGridLayout() + grid.setSpacing(12) +diff --git c/sb_builder/hints_tab.py i/sb_builder/hints_tab.py +index b78ec69..feba94d 100644 +--- c/sb_builder/hints_tab.py ++++ i/sb_builder/hints_tab.py +@@ -21,13 +21,14 @@ import os + + from PyQt6.QtCore import Qt, pyqtSignal + from PyQt6.QtGui import QColor, QFontDatabase, QKeySequence, QShortcut, QSyntaxHighlighter +-from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, ++from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, + QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QSplitter, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + + from . import upstream + from .dashboard import STATUS + from .packages import chip, load_rows ++from .ui import SP, action, enable + + STATES = ("outdated", "current", "no stanza", "not checked", "nvchecker error") + STATE_COLOR = {"outdated": STATUS["aborted"], "current": STATUS["ok"], +@@ -59,9 +60,8 @@ class HintsTab(QWidget): + self.chips[name].toggled.connect(self.refilter) + chips.addWidget(self.chips[name]) + chips.addStretch() +- self.refresh_btn = QPushButton("Refresh") +- self.refresh_btn.clicked.connect(self.reload) +- chips.addWidget(self.refresh_btn) ++ self.status = QLabel() ++ chips.addWidget(self.status) + + self.table = QTableWidget(0, len(COLUMNS)) + self.table.setHorizontalHeaderLabels(COLUMNS) +@@ -74,37 +74,36 @@ class HintsTab(QWidget): + self.table.setColumnWidth(col, width) + self.table.cellClicked.connect(self._toggle) + +- self.status = QLabel() +- self.check_btn = QPushButton("Check upstream") +- self.check_btn.clicked.connect(self.check) +- self.update_btn = QPushButton("Update selected") +- self.update_btn.clicked.connect(self.update_selected) +- self.no_dl = QCheckBox("no download") +- self.no_dl.setToolTip('Write DOWNLOAD="no": slackrepo uses the source already in its cache') +- bar = QHBoxLayout() +- bar.addWidget(self.status) +- self.keys = [] +- for label, on, key in (("Select visible", True, "Ctrl+A"), ("Clear", False, "Ctrl+Shift+A")): +- b = QPushButton(label, toolTip=key) +- b.clicked.connect(lambda _c, on=on: self.check_rows(on)) +- bar.addWidget(b) +- # in the search field Ctrl+A still selects its text: QLineEdit overrides it +- self.keys.append(QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on), +- context=Qt.ShortcutContext.WidgetWithChildrenShortcut)) +- bar.addStretch() +- for w in (self.check_btn, self.update_btn, self.no_dl): +- bar.addWidget(w) +- delete = QPushButton("Delete hint") +- delete.clicked.connect(self.delete) +- bar.addWidget(delete) ++ self.act_check = action(self, "Check upstream", "system-search", ++ SP.SP_FileDialogContentsView, self.check) ++ self.act_update = action(self, "Update selected", "document-save", SP.SP_DialogSaveButton, ++ self.update_selected) ++ self.act_delete = action(self, "Delete hint", "edit-delete", SP.SP_TrashIcon, self.delete) ++ self.actions = [self.act_check, self.act_update, self.act_delete] ++ self.no_dl = action(self, "No download", "network-offline", SP.SP_DriveNetIcon, checkable=True) ++ self.no_dl.setToolTip('Update selected writes DOWNLOAD="no": slackrepo uses the source ' ++ "already in its cache") ++ self.view_actions = [ ++ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, ++ lambda: self.check_rows(True), shortcut="Ctrl+A"), ++ action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, ++ lambda: self.check_rows(False), shortcut="Ctrl+Shift+A"), ++ self.no_dl, ++ action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] ++ # in the search field Ctrl+A still selects its text: QLineEdit overrides it ++ self.keys = [QShortcut(QKeySequence(key), self, lambda on=on: self.check_rows(on), ++ context=Qt.ShortcutContext.WidgetWithChildrenShortcut) ++ for key, on in (("Ctrl+A", True), ("Ctrl+Shift+A", False))] ++ self.loaded = False + + layout = QVBoxLayout(self) + layout.addWidget(self.query) + layout.addLayout(chips) +- layout.addWidget(self.table) +- layout.addLayout(bar) ++ layout.addWidget(self.table, 1) ++ self.refilter() + + def reload(self): ++ self.loaded = True + self.status.setText("loading…") + self.remote.call(self._loaded, "inventory") + +@@ -152,8 +151,12 @@ class HintsTab(QWidget): + hide = (on and state not in on) or text not in name.lower() + self.table.setRowHidden(i, bool(hide)) + shown += not hide +- n = len(self.checked()) +- self.status.setText(f"{shown} shown · {n} selected" + (f" · {self.failure}" if self.failure else "")) ++ ticked = set(self.checked()) ++ self.status.setText(f"{shown} shown · {len(ticked)} selected" ++ + (f" · {self.failure}" if self.failure else "")) ++ enable(self.act_update, None if self.pairs() else "tick outdated packages after Check upstream") ++ enable(self.act_delete, None if any(r["hint_path"] for r in self.rows if r["name"] in ticked) ++ else "none of the ticked packages has a hint") + + def _toggle(self, row, _col): + """A click anywhere on a row, its box included, ticks or unticks it.""" +@@ -187,20 +190,24 @@ class HintsTab(QWidget): + return + + def check(self): +- self.check_btn.setEnabled(False) ++ enable(self.act_check, "checking…") + self.status.setText("checking upstream…") + self.checker.run(os.path.expanduser(self.cfg["hints"]["nvchecker_keyfile"]), self.stanzas) + + def _checked(self, versions, errors, failure): +- self.check_btn.setEnabled(True) ++ enable(self.act_check, None) + self.versions, self.errors, self.failure = versions, errors, failure + self._fill(check_outdated=True) + +- def update_selected(self): ++ def pairs(self): ++ """pkg=version of the ticked packages Check upstream found a version for.""" + versions = self.versions or {} +- pairs = [f"{n}={versions[n]}" for n in self.checked() if n in versions] +- if pairs: +- self.start_requested.emit(["hint-update", *pairs] + (["--no-dl"] if self.no_dl.isChecked() else [])) ++ return [f"{n}={versions[n]}" for n in self.checked() if n in versions] ++ ++ def update_selected(self): ++ if self.pairs(): ++ self.start_requested.emit(["hint-update", *self.pairs()] ++ + (["--no-dl"] if self.no_dl.isChecked() else [])) + + def confirm(self, title, text): + return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes +diff --git c/sb_builder/jobs.py i/sb_builder/jobs.py +index b413597..111d804 100644 +--- c/sb_builder/jobs.py ++++ i/sb_builder/jobs.py +@@ -14,18 +14,19 @@ + # with this program; if not, write to the Free Software Foundation, Inc., + # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +-"""Jobs tab: job list, live log through job-follow, start and kill.""" ++"""Jobs tab: job list, live log through job-follow, start and kill; the next-step dialog.""" + + import codecs + import re + + from PyQt6.QtCore import Qt, pyqtSignal + from PyQt6.QtGui import QFontDatabase, QTextCursor +-from PyQt6.QtWidgets import (QCheckBox, QHBoxLayout, QInputDialog, QLineEdit, QListWidget, +- QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton, +- QSplitter, QToolButton, QVBoxLayout, QWidget) ++from PyQt6.QtWidgets import (QCheckBox, QDialog, QHBoxLayout, QInputDialog, QLineEdit, ++ QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, ++ QPushButton, QSplitter, QVBoxLayout, QWidget) + +-from .fmt import describe ++from .fmt import describe, job_title ++from .ui import SP, action, enable + + ANSI = re.compile(rb"\x1b\[[0-9;?]*[A-Za-z]") + SHADOW_PLAN = re.compile(r"^(un)?shadow: ", re.M) +@@ -33,10 +34,88 @@ ACTIVE = ("starting", "running") + BAD = ("failed", "killed", "lost") + + ++def is_preview(j): ++ """A finished preview whose next step is a real run: slackrepo --preview, sbpull/sbshadow --no.""" ++ return j["status"] == "ok" and ( ++ (j["kind"] == "slackrepo" and "--preview" in j["args"]) ++ or (j["kind"] in ("sbpull", "sbshadow") and "--no" in j["args"])) ++ ++ ++class NextStep(QDialog): ++ """After a preview: its whole log, then Run it (or Apply shadowing) / Not now. ++ ++ run(job-start args) asks for the real run; the caller starts it. For a ++ shadowing preview, loaded(has_plan) tells whether there is anything to apply. ++ """ ++ ++ run = pyqtSignal(list) ++ loaded = pyqtSignal(bool) ++ ++ def __init__(self, remote, job, publish, parent=None): ++ super().__init__(parent) ++ self.job = job ++ self.shadow = job["kind"] != "slackrepo" ++ self.setWindowTitle("Preview: " + job_title(dict(job, args=[ ++ a for a in job["args"] if a not in ("--preview", "--no")]))) ++ self.resize(900, 600) ++ self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") ++ self.log = QPlainTextEdit(readOnly=True) ++ self.log.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) ++ self.publish = QCheckBox("publish", checked=publish, visible=not self.shadow) ++ self.publish.setToolTip("Push to the mirror when the build ends; unticked, use Publish later") ++ self.run_btn = QPushButton("Apply shadowing" if self.shadow else "Run it", default=True) ++ self.run_btn.clicked.connect(self._run) ++ later = QPushButton("Not now") ++ later.clicked.connect(self.reject) ++ bar = QHBoxLayout() ++ bar.addWidget(self.publish) ++ bar.addStretch() ++ bar.addWidget(later) ++ bar.addWidget(self.run_btn) ++ layout = QVBoxLayout(self) ++ layout.addWidget(self.log) ++ layout.addLayout(bar) ++ self.proc = p = remote.follow(job["id"], 0) # the job has ended: the stream closes by itself ++ p.readyReadStandardOutput.connect(self._chunk) ++ p.finished.connect(self._ended) ++ ++ def _chunk(self): ++ data = bytes(self.proc.readAllStandardOutput()) ++ self.log.appendPlainText(self.decoder.decode(ANSI.sub(b"", data)).rstrip("\n")) ++ ++ def _ended(self, *_): ++ self._chunk() ++ self.proc.deleteLater() ++ if not self.has_plan(): ++ self.run_btn.setEnabled(False) ++ self.run_btn.setToolTip("no shadow: or unshadow: lines, nothing to apply") ++ self.loaded.emit(self.has_plan()) ++ ++ def has_plan(self): ++ return not self.shadow or bool(SHADOW_PLAN.search(self.log.toPlainText())) ++ ++ def confirm(self, question): ++ return QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes ++ ++ def _run(self): ++ if self.shadow: ++ if not self.confirm("Apply the shadowing plan to the repo (sbshadow --yes)?"): ++ return ++ self.run.emit(["sbshadow", "--yes"]) ++ else: ++ args = [a for a in self.job["args"] if a != "--preview"] ++ if not self.publish.isChecked(): ++ args.insert(1, "--no-publish") ++ self.run.emit(["slackrepo", *args]) ++ self.accept() ++ ++ + class JobsTab(QWidget): + refresh_requested = pyqtSignal() ++ started = pyqtSignal(str) # id of a job this GUI started ++ preview_requested = pyqtSignal(dict) # a finished preview to take to its next step + +- def __init__(self, remote, publish=True, parent=None): ++ def __init__(self, remote, parent=None): + super().__init__(parent) + self.remote = remote + self.pending = () +@@ -46,25 +125,17 @@ class JobsTab(QWidget): + self.proc = None + self.decoder = codecs.getincrementaldecoder("utf-8")(errors="replace") + +- self.start_menu = QMenu(self) +- start = QToolButton(text="Start", popupMode=QToolButton.ToolButtonPopupMode.InstantPopup) +- start.setMenu(self.start_menu) +- self.apply_btn = QPushButton("Apply shadowing") +- self.apply_btn.clicked.connect(lambda: self.confirm_start( +- "Apply the shadowing plan to the repo (sbshadow --yes)?", "sbshadow", "--yes")) +- self.apply_btn.hide() +- self.publish_box = QCheckBox("publish", checked=publish) +- self.publish_box.setToolTip("Push to the mirror when the build ends; " +- "unticked, use Publish later") +- self.run_btn = QPushButton("Run it") +- self.run_btn.clicked.connect(self.run_preview) +- self.kill_btn = QPushButton("Kill") +- self.kill_btn.clicked.connect(self.kill) +- bar = QHBoxLayout() +- for w in (start, self.apply_btn, self.run_btn, self.publish_box): +- bar.addWidget(w) +- bar.addStretch() +- bar.addWidget(self.kill_btn) ++ self.pull_menu = QMenu(self) ++ self.act_pull = action(self, "Pull overlay", "folder-download", SP.SP_ArrowDown) ++ self.act_pull.setMenu(self.pull_menu) ++ self.act_run = action(self, "Run it", "media-playback-start", SP.SP_MediaPlay, ++ lambda: self.preview_requested.emit(self.jobs[self.current])) ++ self.act_kill = action(self, "Kill", "process-stop", SP.SP_BrowserStop, self.kill) ++ self.actions = [self.act_pull, ++ action(self, "Check shadowing", "edit-find", SP.SP_FileDialogContentsView, ++ lambda: self.start("sbshadow", "--no")), ++ self.act_run, self.act_kill] ++ self.view_actions = [] + + self.list = QListWidget() + self.list.currentItemChanged.connect(self._on_select) +@@ -76,19 +147,14 @@ class JobsTab(QWidget): + split.addWidget(self.log) + split.setSizes([300, 700]) + layout = QVBoxLayout(self) +- layout.addLayout(bar) + layout.addWidget(split) + self.set_overlays([]) + + def set_overlays(self, overlays): +- m = self.start_menu +- m.clear() ++ self.pull_menu.clear() + for o in overlays: +- m.addAction(f"sbpull {o} (preview)", lambda o=o: self.start("sbpull", o, "--no")) +- m.addSeparator() +- m.addAction("sbshadow (preview)", lambda: self.start("sbshadow", "--no")) +- m.addAction("sbshadow (apply)…", lambda: self.confirm_start( +- "Run sbshadow --yes on the repo?", "sbshadow", "--yes")) ++ self.pull_menu.addAction(f"{o} (preview)", lambda o=o: self.start("sbpull", o, "--no")) ++ enable(self.act_pull, None if overlays else "no overlays known yet") + + def set_jobs(self, jobs): + self.jobs = {j["id"]: j for j in jobs} +@@ -140,23 +206,12 @@ class JobsTab(QWidget): + self.pending = args + self.remote.call(self._started, "job-start", *args) + +- def confirm_start(self, question, *args): +- if QMessageBox.question(self, "Confirm", question) == QMessageBox.StandardButton.Yes: +- self.start(*args) +- + def kill(self): + if QMessageBox.question(self, "Kill job", f"Send SIGTERM to {self.current}?") \ + == QMessageBox.StandardButton.Yes: + self.remote.call(lambda _d, err: err and QMessageBox.warning(self, "Kill failed", err), + "job-kill", self.current) + +- def run_preview(self): +- """Start the previewed slackrepo run for real.""" +- args = [a for a in self.jobs[self.current]["args"] if a != "--preview"] +- if not self.publish_box.isChecked(): +- args.insert(1, "--no-publish") +- self.start_signed("slackrepo", *args) +- + def ask_passphrase(self, retry): + text, ok = QInputDialog.getText( + self, "Signing key", ("Wrong passphrase, try again." if retry else +@@ -206,6 +261,7 @@ class JobsTab(QWidget): + self.select(data["busy"]) + return + self.select(data["id"]) ++ self.started.emit(data["id"]) + self.refresh_requested.emit() + + def _on_select(self, item, _prev): +@@ -243,11 +299,5 @@ class JobsTab(QWidget): + + def _update_buttons(self): + j = self.jobs.get(self.current) +- self.kill_btn.setEnabled(bool(j) and j["status"] == "running") +- self.apply_btn.setVisible( +- bool(j) and j["status"] == "ok" and "--no" in j["args"] +- and bool(SHADOW_PLAN.search(self.log.toPlainText()))) +- preview = bool(j) and j["kind"] == "slackrepo" and j["status"] == "ok" \ +- and "--preview" in j["args"] +- self.run_btn.setVisible(preview) +- self.publish_box.setVisible(preview) ++ enable(self.act_kill, None if j and j["status"] == "running" else "select a running job") ++ enable(self.act_run, None if j and is_preview(j) else "select a finished preview") +diff --git c/sb_builder/mainwindow.py i/sb_builder/mainwindow.py +index eeac61a..c6ea02e 100644 +--- c/sb_builder/mainwindow.py ++++ i/sb_builder/mainwindow.py +@@ -21,26 +21,24 @@ import time + from PyQt6.QtCore import QMetaType, QObject, QSize, Qt, QTimer, pyqtSignal, pyqtSlot + from PyQt6.QtDBus import QDBusArgument, QDBusConnection, QDBusInterface, QDBusMessage + from PyQt6.QtGui import QIcon, QKeySequence, QShortcut +-from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabBar, +- QTabWidget, QToolBar) ++from PyQt6.QtWidgets import (QLabel, QMainWindow, QMessageBox, QPushButton, QStyle, QTabWidget, ++ QToolBar, QToolButton) + + from . import VERSION, config + from .dashboard import Dashboard + from .fmt import job_title + from .hints_tab import HINT_JOBS, HintResultDialog, HintsTab +-from .jobs import JobsTab ++from .jobs import JobsTab, NextStep, is_preview + from .packages import PackagesTab + from .results import ResultsTab + from .remote import DOWN, MISSING + + NOTIFY = "org.freedesktop.Notifications" + NOTIFY_PATH = "/org/freedesktop/Notifications" +-# ribbon entries: label, freedesktop icon name, Qt fallback icon +-RIBBON = (("Dashboard", "go-home", QStyle.StandardPixmap.SP_DirHomeIcon), +- ("Packages", "package-x-generic", QStyle.StandardPixmap.SP_FileDialogDetailedView), +- ("Hints", "document-edit", QStyle.StandardPixmap.SP_FileDialogContentsView), +- ("Results", "view-list-text", QStyle.StandardPixmap.SP_MessageBoxWarning), +- ("Jobs", "system-run", QStyle.StandardPixmap.SP_MediaPlay)) ++# Jobs tab icon: running, then the end status until the tab is shown ++BADGE = {"running": ("system-run", QStyle.StandardPixmap.SP_MediaPlay), ++ "ok": ("emblem-ok", QStyle.StandardPixmap.SP_DialogApplyButton), ++ "bad": ("dialog-error", QStyle.StandardPixmap.SP_MessageBoxCritical)} + # tabs as a distinct, centered row; colors from the palette so the theme applies + TAB_STYLE = """ + QTabWidget::tab-bar { alignment: center; } +@@ -86,11 +84,10 @@ class MainWindow(QMainWindow): + self.cfg, self.remote = cfg, remote + self.notifier = notifier or Notifier(self) + self.notifier.clicked.connect(self.open_job) +- self.jobs_tab = None +- self.packages_tab = None +- self.results_tab = None +- self.hints_tab = None + self.hint_dialogs = [] # open HintResultDialogs, kept alive while shown ++ self.next_steps = {} # job id -> its open NextStep dialog ++ self.mine = set() # ids of the jobs this session started: only they get next steps ++ self.badge = None # Jobs tab icon state: None, "running", "ok", "bad" + self.seen = None # job id -> status at the last poll; None before the first + self.online = None # None until the first answer + self.overlays = [] +@@ -99,17 +96,29 @@ class MainWindow(QMainWindow): + self.running_id = None + self.setWindowTitle("sb-builder") + +- self.tabs = QTabWidget(tabsClosable=True) ++ self.tabs = QTabWidget() + self.tabs.setStyleSheet(TAB_STYLE) +- self.tabs.tabCloseRequested.connect(self._close_tab) +- self.tabs.currentChanged.connect(lambda _i: self.poll_stats()) + self.dashboard = Dashboard() +- self.dashboard.refresh_btn.clicked.connect(self.poll_stats) ++ self.dashboard.refresh.triggered.connect(self.poll_stats) + self.dashboard.tiles["Packages"].link(self.open_packages) + self.dashboard.jobs.link(self.open_jobs) + self.dashboard.result_clicked.connect(self.open_results) +- self.tabs.addTab(self.dashboard, "Dashboard") +- self.tabs.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None) ++ self.jobs_tab = JobsTab(self.remote) ++ self.jobs_tab.refresh_requested.connect(self.poll_jobs) ++ self.jobs_tab.started.connect(self.mine.add) ++ self.jobs_tab.preview_requested.connect(self.show_next_step) ++ self.packages_tab = PackagesTab(self.remote) ++ self.packages_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args)) ++ self.packages_tab.hints_requested.connect(self.open_hints) ++ self.hints_tab = HintsTab(self.remote, self.cfg) ++ self.hints_tab.start_requested.connect(lambda args: self.jobs_tab.start(*args)) ++ self.hints_tab.result_ready.connect(self.show_hint_result) ++ self.results_tab = ResultsTab(self.remote) ++ # permanent tabs (UI guidelines); each loads on first show ++ for tab, label in ((self.dashboard, "Dashboard"), (self.packages_tab, "Packages"), ++ (self.hints_tab, "Hints"), (self.results_tab, "Results"), ++ (self.jobs_tab, "Jobs")): ++ self.tabs.addTab(tab, label) + self.setCentralWidget(self.tabs) + + file_menu = self.menuBar().addMenu("&File") +@@ -118,17 +127,18 @@ class MainWindow(QMainWindow): + # window-wide: QTabWidget's own Ctrl+PgUp/PgDn needs focus on the tabs + self.tab_next = QShortcut(QKeySequence("Ctrl+PgDown"), self, lambda: self._cycle_tab(1)) + self.tab_prev = QShortcut(QKeySequence("Ctrl+PgUp"), self, lambda: self._cycle_tab(-1)) +- self.ribbon = QToolBar("Screens", movable=False, floatable=False) ++ # main ribbon: the current tab's actions; small ribbon under it: its view controls ++ self.ribbon = QToolBar("Actions", movable=False, floatable=False) + self.ribbon.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextUnderIcon) + self.ribbon.setIconSize(QSize(32, 32)) + self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.ribbon) +- slots = {"Dashboard": lambda: self.tabs.setCurrentWidget(self.dashboard), +- "Packages": self.open_packages, "Hints": lambda: self.open_hints(), +- "Results": lambda: self.open_results(), +- "Jobs": self.open_jobs} +- for label, name, fallback in RIBBON: +- icon = QIcon.fromTheme(name, self.style().standardIcon(fallback)) +- self.ribbon.addAction(icon, label, slots[label]) ++ self.addToolBarBreak() ++ self.view_bar = QToolBar("View", movable=False, floatable=False) ++ self.view_bar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) ++ self.view_bar.setIconSize(QSize(16, 16)) ++ self.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.view_bar) ++ self.tabs.currentChanged.connect(self._tab_changed) ++ self._tab_changed() + + self.conn = QLabel("● connecting") + self.key = QLabel() +@@ -179,9 +189,12 @@ class MainWindow(QMainWindow): + jobs = data["jobs"] + self._notify_ends(jobs) + self.dashboard.show_jobs(jobs) +- if self.jobs_tab: +- self.jobs_tab.set_jobs(jobs) ++ self.jobs_tab.set_jobs(jobs) + run = next((j for j in jobs if j["status"] == "running"), None) ++ if run: ++ self._badge("running") ++ elif self.badge == "running": ++ self._badge(None) # ended unseen (first poll, reconnect): nothing to report + self.running_id = run and run["id"] + self.job_btn.setVisible(run is not None) + if run: +@@ -196,8 +209,7 @@ class MainWindow(QMainWindow): + if err: + return self._failed(err) + self.overlays = data.get("overlays", []) +- if self.jobs_tab: +- self.jobs_tab.set_overlays(self.overlays) ++ self.jobs_tab.set_overlays(self.overlays) + if data["version"] != VERSION: + self.conn.setText(f"● helper {data['version']}, GUI {VERSION}: run ./install.sh --remote") + +@@ -285,24 +297,57 @@ class MainWindow(QMainWindow): + event = END_EVENT.get(j["status"]) + if event and prev.get(j["id"], "running") != j["status"]: + self.notify(event, job_title(j), j["id"]) ++ if self.tabs.currentWidget() is not self.jobs_tab: ++ self._badge("ok" if j["status"] == "ok" else "bad") + if j["kind"] in ("slackrepo", "publish"): # build state changed + self._reload(self.packages_tab, self.results_tab) + if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"): + self._reload(self.packages_tab, self.hints_tab) ++ if j["id"] not in self.mine: ++ continue # started elsewhere or before this session: news, not a next step ++ if j["kind"] in HINT_JOBS and j["status"] in ("ok", "failed"): + self.remote.call(lambda d, err: d and self.show_hint_result(d), "job-result", j["id"]) ++ elif is_preview(j): ++ self.show_next_step(j, auto=True) + + @staticmethod + def _reload(*tabs): + for tab in tabs: +- if tab: ++ if tab.loaded: # a tab not shown yet loads fresh when it is + tab.reload() + ++ def show_next_step(self, job, auto=False): ++ """The preview's next step as a dialog; auto: a shadowing preview without a plan shows none.""" ++ if job["id"] in self.next_steps: ++ self.next_steps[job["id"]].raise_() ++ return ++ d = NextStep(self.remote, job, self.cfg["builds"]["publish"], self) ++ d.run.connect(lambda args: (self.jobs_tab.start_signed if args[0] == "slackrepo" ++ else self.jobs_tab.start)(*args)) ++ d.finished.connect(lambda _r: self.next_steps.pop(job["id"], None)) ++ self.next_steps[job["id"]] = d ++ if d.shadow and auto: ++ d.loaded.connect(lambda plan: d.show() if plan else d.reject()) ++ else: ++ d.show() ++ ++ def _badge(self, state): ++ self.badge = state ++ i = self.tabs.indexOf(self.jobs_tab) ++ # in the text, not a color: TAB_STYLE's tab color would override setTabTextColor ++ self.tabs.setTabText(i, "Jobs (running)" if state == "running" else "Jobs") ++ if state is None: ++ self.tabs.setTabIcon(i, QIcon()) ++ return ++ name, fallback = BADGE[state] ++ self.tabs.setTabIcon(i, QIcon.fromTheme(name, self.style().standardIcon(fallback))) ++ + def show_hint_result(self, result): + if not result["ok"] and not result["failed"]: + return # nothing changed, nothing failed: no news + d = HintResultDialog(self.remote, result, self) + d.build_requested.connect( +- lambda pkgs: self.open_jobs().start("slackrepo", "update", "--preview", *pkgs)) ++ lambda pkgs: self.jobs_tab.start("slackrepo", "update", "--preview", *pkgs)) + d.changed.connect(lambda: self._reload(self.packages_tab, self.hints_tab)) + d.finished.connect(lambda _r: self.hint_dialogs.remove(d)) + self.hint_dialogs.append(d) +@@ -310,49 +355,42 @@ class MainWindow(QMainWindow): + + # ── tabs ──────────────────────────────────────────────────────────────── + +- def open_jobs(self): +- if self.jobs_tab is None: +- self.jobs_tab = JobsTab(self.remote, self.cfg["builds"]["publish"]) +- self.jobs_tab.refresh_requested.connect(self.poll_jobs) +- self.jobs_tab.set_overlays(self.overlays) +- self.tabs.addTab(self.jobs_tab, "Jobs") +- self.poll_jobs() +- self.tabs.setCurrentWidget(self.jobs_tab) +- return self.jobs_tab ++ def _tab_changed(self, _i=None): ++ tab = self.tabs.currentWidget() ++ for bar, acts in ((self.ribbon, tab.actions), (self.view_bar, tab.view_actions)): ++ bar.clear() ++ bar.addActions(acts) ++ for a in acts: ++ if a.menu(): # a menu action opens its menu on a plain click ++ bar.widgetForAction(a).setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) ++ self.view_bar.setVisible(bool(tab.view_actions)) ++ if not getattr(tab, "loaded", True): ++ tab.reload() ++ if tab is self.jobs_tab and self.badge != "running": ++ self._badge(None) ++ self.poll_stats() + + def _cycle_tab(self, step): + self.tabs.setCurrentIndex((self.tabs.currentIndex() + step) % self.tabs.count()) + ++ def open_jobs(self): ++ self.tabs.setCurrentWidget(self.jobs_tab) ++ return self.jobs_tab ++ + def open_packages(self): +- if self.packages_tab is None: +- self.packages_tab = PackagesTab(self.remote) +- self.packages_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) +- self.packages_tab.hints_requested.connect(self.open_hints) +- self.tabs.addTab(self.packages_tab, "Packages") +- self.packages_tab.reload() + self.tabs.setCurrentWidget(self.packages_tab) + return self.packages_tab + + def open_hints(self, name=None): +- if self.hints_tab is None: +- self.hints_tab = HintsTab(self.remote, self.cfg) +- self.hints_tab.start_requested.connect(lambda args: self.open_jobs().start(*args)) +- self.hints_tab.result_ready.connect(self.show_hint_result) +- self.tabs.addTab(self.hints_tab, "Hints") +- self.hints_tab.reload() ++ self.tabs.setCurrentWidget(self.hints_tab) + if name: + self.hints_tab.select(name) +- self.tabs.setCurrentWidget(self.hints_tab) + return self.hints_tab + + def open_results(self, result=None): +- if self.results_tab is None: +- self.results_tab = ResultsTab(self.remote) +- self.tabs.addTab(self.results_tab, "Results") +- self.results_tab.reload() ++ self.tabs.setCurrentWidget(self.results_tab) + if result: + self.results_tab.filter_to(result) +- self.tabs.setCurrentWidget(self.results_tab) + return self.results_tab + + def open_job(self, jid): +@@ -362,22 +400,6 @@ class MainWindow(QMainWindow): + if jid: + self.open_jobs().select(jid) + +- def _close_tab(self, i): +- w = self.tabs.widget(i) +- if w is self.dashboard: +- return +- self.tabs.removeTab(i) +- if w is self.jobs_tab: +- self.jobs_tab = None +- w.stop() +- if w is self.packages_tab: +- self.packages_tab = None +- if w is self.results_tab: +- self.results_tab = None +- if w is self.hints_tab: +- self.hints_tab = None +- w.deleteLater() +- + def reload_config(self): + cfg, err = config.load() + if err: +diff --git c/sb_builder/packages.py i/sb_builder/packages.py +index 64032a2..30215e0 100644 +--- c/sb_builder/packages.py ++++ i/sb_builder/packages.py +@@ -20,13 +20,13 @@ import html + + from PyQt6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt, pyqtSignal + from PyQt6.QtGui import QColor, QKeySequence, QShortcut +-from PyQt6.QtWidgets import (QCheckBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, +- QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, QSplitter, +- QTableView, QVBoxLayout, QWidget) ++from PyQt6.QtWidgets import (QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QScrollArea, ++ QSplitter, QTableView, QVBoxLayout, QWidget) + + from . import hints as hintlib + from .dashboard import STATUS + from .fmt import NA, fmt_time ++from .ui import SP, action, enable + + OVERLAYS = ("personal", "pentesting") + STATUSES = ("ok", "failed", "never", "outdated") +@@ -230,9 +230,8 @@ class PackagesTab(QWidget): + chips.addWidget(self.chips[name]) + chips.addSpacing(16) + chips.addStretch() +- self.refresh_btn = QPushButton("Refresh") +- self.refresh_btn.clicked.connect(self.reload) +- chips.addWidget(self.refresh_btn) ++ self.count = QLabel() ++ chips.addWidget(self.count) + + self.table = QTableView(sortingEnabled=True) + self.table.setModel(self.proxy) +@@ -248,18 +247,14 @@ class PackagesTab(QWidget): + alignment=Qt.AlignmentFlag.AlignTop) + self.detail.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + self.detail.setContentsMargins(12, 8, 12, 8) +- self.new_hint_btn = QPushButton("New hint") +- self.new_hint_btn.clicked.connect(self.new_hint) +- self.open_hint_btn = QPushButton("Open in Hints") +- self.open_hint_btn.clicked.connect(lambda: self.hints_requested.emit(self.current()["name"])) +- detail_bar = QHBoxLayout() +- detail_bar.addWidget(self.new_hint_btn) +- detail_bar.addWidget(self.open_hint_btn) +- detail_bar.addStretch() ++ # navigation, not an action: a link in the pane (UI guidelines) ++ self.open_hint_btn = QLabel('<a href="#">Open in Hints</a>') ++ self.open_hint_btn.setContentsMargins(12, 0, 12, 8) ++ self.open_hint_btn.linkActivated.connect(lambda _l: self.hints_requested.emit(self.current()["name"])) + pane = QWidget() + pane_layout = QVBoxLayout(pane) + pane_layout.addWidget(self.detail) +- pane_layout.addLayout(detail_bar) ++ pane_layout.addWidget(self.open_hint_btn) + pane_layout.addStretch() + scroll = QScrollArea(widgetResizable=True) + scroll.setWidget(pane) +@@ -269,37 +264,38 @@ class PackagesTab(QWidget): + split.addWidget(scroll) + split.setSizes([700, 400]) + +- self.count = QLabel() +- bar = QHBoxLayout() +- bar.addWidget(self.count) +- self.keys = [] +- for label, slot, key in (("Select visible", self.select_visible, "Ctrl+A"), +- ("Clear", self.clear, "Ctrl+Shift+A")): +- b = QPushButton(label, toolTip=key) +- b.clicked.connect(slot) +- bar.addWidget(b) +- # in the search field Ctrl+A still selects its text: QLineEdit overrides it +- self.keys.append(QShortcut(QKeySequence(key), self, slot, +- context=Qt.ShortcutContext.WidgetWithChildrenShortcut)) +- bar.addStretch() +- for action in ("build", "update", "rebuild", "remove"): +- b = QPushButton(action.capitalize()) +- b.clicked.connect(lambda _c, a=action: self.act(a)) +- bar.addWidget(b) +- for label, slot in (("Update all", self.update_all), ("Publish", self.publish)): +- b = QPushButton(label) +- b.clicked.connect(slot) +- bar.addWidget(b) ++ self.act_build = { ++ a: action(self, a.capitalize(), icon, fb, lambda a=a: self.act(a)) ++ for a, icon, fb in (("build", "run-build", SP.SP_MediaPlay), ++ ("update", "system-software-update", SP.SP_BrowserReload), ++ ("rebuild", "view-refresh", SP.SP_BrowserReload), ++ ("remove", "edit-delete", SP.SP_TrashIcon))} ++ self.act_new_hint = action(self, "New hint", "document-new", SP.SP_FileIcon, self.new_hint) ++ self.actions = [*self.act_build.values(), ++ action(self, "Update all", "system-upgrade", SP.SP_ArrowUp, self.update_all), ++ self.act_new_hint, ++ action(self, "Publish", "document-send", SP.SP_DialogSaveButton, self.publish)] ++ self.view_actions = [ ++ action(self, "Select visible", "edit-select-all", SP.SP_DialogYesButton, ++ self.select_visible, shortcut="Ctrl+A"), ++ action(self, "Clear", "edit-clear", SP.SP_DialogResetButton, self.clear, ++ shortcut="Ctrl+Shift+A"), ++ action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] ++ # in the search field Ctrl+A still selects its text: QLineEdit overrides it ++ self.keys = [QShortcut(QKeySequence(key), self, slot, ++ context=Qt.ShortcutContext.WidgetWithChildrenShortcut) ++ for key, slot in (("Ctrl+A", self.select_visible), ("Ctrl+Shift+A", self.clear))] ++ self.loaded = False + + layout = QVBoxLayout(self) + layout.addWidget(self.query) + layout.addLayout(chips) +- layout.addWidget(split) +- layout.addLayout(bar) ++ layout.addWidget(split, 1) + self.model.dataChanged.connect(lambda *_: self.update_count()) + self.update_count() + + def reload(self): ++ self.loaded = True + self.count.setText("loading…") + self.remote.call(self._loaded, "inventory") + +@@ -317,6 +313,15 @@ class PackagesTab(QWidget): + + def update_count(self): + self.count.setText(f"{self.proxy.rowCount():,} shown · {len(self.model.checked)} selected") ++ for a in self.act_build.values(): ++ enable(a, None if self.model.checked else "tick packages first") ++ enable(self.act_new_hint, None if self.without_hint() else ++ "none of the ticked packages is without a hint") ++ ++ def without_hint(self): ++ """Ticked packages that have no hint, sorted.""" ++ return sorted(r["name"] for r in self.model.rows ++ if r["name"] in self.model.checked and not r["hint_path"]) + + def _toggle(self, index): + """A click anywhere on a row ticks or unticks it.""" +@@ -360,30 +365,9 @@ class PackagesTab(QWidget): + def show_detail(self, current, _previous): + row = current.data(Qt.ItemDataRole.UserRole) if current.isValid() else None + self.detail.setText(detail_html(row) if row else "") +- self.new_hint_btn.setVisible(bool(row) and not row["hint_path"]) + self.open_hint_btn.setVisible(bool(row) and bool(row["hint_path"])) + +- def ask_new_hint(self, name): +- """(version or "", no download) for a new hint, None on cancel.""" +- d = QDialog(self, windowTitle=f"New hint: {name}") +- version = QLineEdit(placeholderText="the .info version") +- no_dl = QCheckBox('no download (DOWNLOAD="no")') +- buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok +- | QDialogButtonBox.StandardButton.Cancel) +- buttons.accepted.connect(d.accept) +- buttons.rejected.connect(d.reject) +- form = QFormLayout(d) +- form.addRow("version", version) +- form.addRow(no_dl) +- form.addRow(buttons) +- if d.exec() != QDialog.DialogCode.Accepted: +- return None +- return version.text().strip(), no_dl.isChecked() +- + def new_hint(self): +- row = self.current() +- answer = row and self.ask_new_hint(row["name"]) +- if answer: +- version, no_dl = answer +- self.start_requested.emit(["hint-new", row["name"], *([version] if version else []), +- *(["--no-dl"] if no_dl else [])]) ++ names = self.without_hint() ++ if names: ++ self.start_requested.emit(["hint-new", *names]) +diff --git c/sb_builder/results.py i/sb_builder/results.py +index 1fc6b9a..21fdcc3 100644 +--- c/sb_builder/results.py ++++ i/sb_builder/results.py +@@ -21,12 +21,13 @@ import re + + from PyQt6.QtCore import Qt + from PyQt6.QtGui import QColor, QFontDatabase, QTextCursor +-from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, +- QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) ++from PyQt6.QtWidgets import (QDialog, QHBoxLayout, QLabel, QPlainTextEdit, QSplitter, ++ QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget) + + from .dashboard import STATUS + from .fmt import NA, fmt_time + from .packages import chip ++from .ui import SP, action, enable + + KINDS = ("ok", "failed", "bad", "aborted", "removed") + ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]") +@@ -75,9 +76,10 @@ class ResultsTab(QWidget): + bar.addStretch() + self.count = QLabel() + bar.addWidget(self.count) +- refresh = QPushButton("Refresh") +- refresh.clicked.connect(self.reload) +- bar.addWidget(refresh) ++ self.act_log = action(self, "Show build log", "text-x-generic", SP.SP_FileIcon, self.show_log) ++ self.actions = [self.act_log] ++ self.view_actions = [action(self, "Refresh", "view-refresh", SP.SP_BrowserReload, self.reload)] ++ self.loaded = False + + self.table = QTableWidget(0, 4, sortingEnabled=True) + self.table.setHorizontalHeaderLabels(["result", "package", "built version", "attempt"]) +@@ -94,13 +96,9 @@ class ResultsTab(QWidget): + self.detail = QLabel(textFormat=Qt.TextFormat.RichText, wordWrap=True, + alignment=Qt.AlignmentFlag.AlignTop) + self.detail.setContentsMargins(12, 8, 12, 8) +- self.log_btn = QPushButton("Show build log") +- self.log_btn.clicked.connect(self.show_log) +- self.log_btn.setEnabled(False) + side = QWidget() + col = QVBoxLayout(side) + col.addWidget(self.detail) +- col.addWidget(self.log_btn) + col.addStretch() + split = QSplitter() + split.addWidget(self.table) +@@ -109,9 +107,11 @@ class ResultsTab(QWidget): + + layout = QVBoxLayout(self) + layout.addLayout(bar) +- layout.addWidget(split) ++ layout.addWidget(split, 1) ++ self.show_detail() + + def reload(self): ++ self.loaded = True + self.count.setText("loading…") + self.remote.call(self._loaded, "results") + +@@ -166,7 +166,7 @@ class ResultsTab(QWidget): + + def show_detail(self): + row = self.current() +- self.log_btn.setEnabled(bool(row and row[4])) ++ enable(self.act_log, None if row and row[4] else "select a result with a log") + if not row: + self.detail.setText("") + return +diff --git c/sb_builder/ui.py i/sb_builder/ui.py +new file mode 100644 +index 0000000..087d1a7 +--- /dev/null ++++ i/sb_builder/ui.py +@@ -0,0 +1,46 @@ ++# sb-builder - ribbon actions ++# Copyright (C) 2026 Danilo M. <danix@danix.xyz> ++# ++# This program is free software; you can redistribute it and/or modify ++# it under the terms of the GNU General Public License version 2 as ++# published by the Free Software Foundation. ++# ++# This program is distributed in the hope that it will be useful, ++# but WITHOUT ANY WARRANTY; without even the implied warranty of ++# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ++# GNU General Public License for more details. ++# ++# You should have received a copy of the GNU General Public License along ++# with this program; if not, write to the Free Software Foundation, Inc., ++# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. ++ ++"""Ribbon actions shared by the tabs (UI guidelines in AGENTS.md). ++ ++Each tab owns two lists of QActions: `actions` for the main ribbon (what it ++does, in workflow order) and `view_actions` for the small one (selection and ++view controls). The main window shows the current tab's lists. ++""" ++ ++from PyQt6.QtGui import QAction, QIcon ++from PyQt6.QtWidgets import QStyle ++ ++ ++def action(parent, text, icon, fallback, slot=None, checkable=False, shortcut=None): ++ """A ribbon action: freedesktop icon with a QStyle fallback.""" ++ a = QAction(QIcon.fromTheme(icon, parent.style().standardIcon(fallback)), text, parent, ++ checkable=checkable) ++ if slot: ++ a.triggered.connect(lambda _checked=False: slot()) ++ a.setData(shortcut) # shown in the tooltip; the tab's QShortcut does the work ++ enable(a, None) ++ return a ++ ++ ++def enable(a, reason): ++ """Enable a, or disable it with reason as its tooltip: disabled, never hidden.""" ++ a.setEnabled(not reason) ++ tip = a.text() + (f" ({a.data()})" if a.data() else "") ++ a.setToolTip(f"{tip}: {reason}" if reason else tip) ++ ++ ++SP = QStyle.StandardPixmap +``` + +- [ ] **Step 4:** Run `python3 -m unittest discover -s tests`. Expected: `OK` (115 tests). +- [ ] **Step 5: Look at it** offscreen: each tab's grab shows the main ribbon, the small ribbon (not on Dashboard and Jobs), the tab bar, the filter rows directly above the table. +- [ ] **Step 6: Commit.** + +```bash +git add sb_builder tests/test_gui.py +git commit -m "One frame for every tab: ribbons of actions, next steps as dialogs + +Tabs are permanent and load on first show. The ribbon shows the current +tab's actions in workflow order, a small ribbon under it the view +controls; inapplicable actions are disabled with the reason as tooltip. +Starting a job stays on the tab; the Jobs tab reads (running), then shows +the end status. When a preview this session started ends, its log opens +with Run it (publish box) or Apply shadowing; Not now loses nothing, Run +it in Jobs reopens it. New hint moves to the Packages ribbon and acts on +the ticked packages without a hint. + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +``` + +### Task 3: docs + +**Files:** `README.md`, `AGENTS.md` (new "UI guidelines" section), the UI and phase 4 specs. + +- [ ] **Step 1:** Save the diff below as `/tmp/u3.diff`, then `git apply /tmp/u3.diff`. + +```diff +diff --git c/AGENTS.md i/AGENTS.md +index 9a8918c..f55d952 100644 +--- c/AGENTS.md ++++ i/AGENTS.md +@@ -44,6 +44,27 @@ Design and phasing: `docs/superpowers/specs/`. Phase 1 (core) is + that tool's own repo (e.g. `slackrepo_setup` for sbshadow) rather than + working around it here. + ++## UI guidelines ++ ++Every tab follows these (design: `docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md`). ++ ++1. Tabs are permanent and load their data lazily on first show (`loaded`, ++ `reload()`); the ribbon never opens or closes tabs. ++2. Main ribbon: the tab's actions (`tab.actions`) in workflow order. Small ++ ribbon: view controls (`tab.view_actions`). Build them with ++ `sb_builder/ui.py` `action()`. Nothing actionable lives inside a view ++ except navigation links. ++3. Change actions act on ticked rows, view actions on the highlighted row. A ++ row click toggles its tick; Ctrl+A / Ctrl+Shift+A select visible / clear. ++4. An inapplicable action is disabled with `ui.enable(action, reason)`, the ++ reason in its tooltip; never hidden. ++5. When a job this session started ends, its next step opens as a non-modal ++ dialog where the user is (`MainWindow.mine`, `NextStep`, ++ `HintResultDialog`); starting a job never switches tab; the Jobs tab shows ++ activity; a dismissed step can be taken again from the ribbon. ++6. A modal confirm only for destructive or outward steps (delete hint, ++ publish, apply shadowing). ++ + ## Conventions + + - GUI config: `$XDG_CONFIG_HOME/sb-builder/config.toml`. Helper config: +diff --git c/README.md i/README.md +index 366b740..ccf5edd 100644 +--- c/README.md ++++ i/README.md +@@ -33,45 +33,54 @@ Every key is optional. `File > Reload config` rereads the GUI config. + + sb-builder + ++The window has five tabs, always open: Dashboard, Packages, Hints, Results, ++Jobs (Ctrl+PgUp/PgDn cycle them). The ribbon at the top holds the current ++tab's actions, in the order a task uses them; the smaller bar under it holds ++its view controls (select, clear, refresh). An action that does not apply is ++greyed out, its tooltip says why. Actions act on the **ticked** rows: a click ++anywhere on a row ticks or unticks it, Ctrl+A ticks every visible row, ++Ctrl+Shift+A unticks all. ++ ++Starting a job keeps you where you are; the Jobs tab shows "(running)" and ++then the end status. When a job you started ends, its next step comes to you ++as a dialog: after a preview, its log with **Run it** (and a **publish** box); ++after a hint change, the diff with **Revert** and **Build these**. **Not ++now** loses nothing: **Run it** in the Jobs tab, on a finished preview, ++reopens it. ++ + - **Dashboard**: VM load, uptime and free disk, package counts, slackrepo + build results, last overlay pull, recent jobs. Refreshes while visible. +-- **Open > Jobs**: start a job from the Start menu (`sbpull <overlay>` +- preview, `sbshadow` preview or apply), follow its log live, kill it. After a +- preview that lists `shadow:` or `unshadow:` lines, "Apply shadowing" runs +- `sbshadow --yes`. +- +-- **Open > Packages** (or click the Packages tile): every package of the +- repository, with built version and status (`ok`, `failed`, `never`, +- `outdated`). Search by name or summary, or with `cat:`, `maint:`, `req:`, +- `status:`; toggle the overlay and status chips. Ctrl+A ticks every visible +- package, Ctrl+Shift+A unticks all (here and in Hints). The detail pane shows the +- package's versions, its dependencies after hints (dropped ones struck +- through), and what its hint file does, section by section. +-- Tick packages and press Build, Update, Rebuild or Remove (they act on the +- ticked packages only); Update all updates everything. Each first runs a slackrepo +- `--preview` job. When the preview is fine, **Run it** in the Jobs tab starts +- the real run; untick **publish** to keep the packages local and press +- **Publish** later to push them. +-- **Results** (ribbon, or click a result in the Dashboard's slackrepo bar or +- legend): slackrepo's last build attempt per package, newest first, filterable +- by result. **Show build log** (or double-click) opens the end of the log of +- the package's last attempt (slackrepo keeps one per command: `build.log`, +- `update.log`, `rebuild.log`, `remove.log`), where it reports the failure, +- with its `resource.log` (load, memory, tmp over the build). +-- **Hints** (ribbon): every package with a hint or an nvchecker stanza, with +- hint, `.info` and upstream version. **Check upstream** runs nvchecker on the +- desktop over `nvchecker.toml` in this repo (stanza name = package name, edit +- it by hand to track a package; `hints.nvchecker_keyfile` gives it a GitHub +- token) and ticks the outdated packages. **Update selected** rewrites their +- hints on the VM (version, URLs, md5 of the downloaded source; **no download** +- writes `DOWNLOAD="no"` instead; a hint sbshadow wrote, `DELREQUIRES` only, +- gets the `.info` version lines added). **Delete hint** removes the ticked +- packages' hints, each kept as a `.bak`. The Packages detail pane has **New hint** for a package +- without one. +-- After a hint job or a delete a dialog shows the diff of each changed hint, with +- **Revert** per package and **Build these** (a slackrepo update preview). +- neovim and openvino are refused for now: their bundled deps still need +- mkhint on the VM. ++ The tiles open Packages, Results (one result kind) and Jobs. ++- **Packages**: every package of the repository, with built version and ++ status (`ok`, `failed`, `never`, `outdated`). Search by name or summary, or ++ with `cat:`, `maint:`, `req:`, `status:`; toggle the overlay and status ++ chips. The detail pane shows the package's versions, its dependencies after ++ hints (dropped ones struck through), what its hint file does, and a link ++ to it in Hints. **Build**, **Update**, **Rebuild**, **Remove** and **Update ++ all** run a slackrepo `--preview` first; **New hint** creates hints, pinned ++ at the `.info` version, for the ticked packages without one; **Publish** ++ pushes the package repository (untick **publish** at Run it to hold a run ++ back and publish later). ++- **Hints**: every package with a hint or an nvchecker stanza, with hint, ++ `.info` and upstream version. **Check upstream** runs nvchecker on the ++ desktop over `nvchecker.toml` in this repo (stanza name = package name, ++ edit it by hand to track a package; `hints.nvchecker_keyfile` gives it a ++ GitHub token) and ticks the outdated packages. **Update selected** rewrites ++ their hints on the VM (version, URLs, md5 of the downloaded source; with ++ **No download** on, `DOWNLOAD="no"` instead; a hint sbshadow wrote, ++ `DELREQUIRES` only, gets the `.info` version lines added). **Delete hint** ++ removes the ticked packages' hints, each kept as a `.bak`, revertable from ++ the dialog that follows. neovim and openvino are refused for now: their ++ bundled deps still need mkhint on the VM. ++- **Results**: slackrepo's last build attempt per package, newest first, ++ filterable by result. **Show build log** (or double-click) opens the end of ++ the log of the package's last attempt (slackrepo keeps one per command: ++ `build.log`, `update.log`, `rebuild.log`, `remove.log`), where it reports ++ the failure, with its `resource.log` (load, memory, tmp over the build). ++- **Jobs**: every job with its live log. **Pull overlay** (`sbpull <overlay>`) ++ and **Check shadowing** (`sbshadow`) run as previews; when one finds ++ `shadow:` or `unshadow:` lines, its dialog offers **Apply shadowing**. ++ **Kill** stops the selected running job. + - Real runs sign the repository. When the package signing key is locked + (status bar 🔒), the GUI asks for its passphrase first and hands it to the + VM's gpg-agent; it is never stored. +diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md +index 775b0c4..b746044 100644 +--- c/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md ++++ i/docs/superpowers/specs/2026-10-20-sb-builder-phase4-design.md +@@ -136,7 +136,8 @@ New key `manifest_packages` (below). `remote/config.example.toml` shows it. + built as `hint-new` does. The last log line is + `SBB-RESULT {"ok": [pkg...], "failed": [[pkg, why]...]}`. Exit status 1 + when any package failed. +-- `hint-new <pkg> [<ver>] [--no-dl]`: refuses when the hint exists. Builds ++- `hint-new <pkg> [<ver>] [--no-dl]` (since 0.6.0 `hint-new <pkg>... [--no-dl]`, ++ no version, see the UI design): refuses when the hint exists. Builds + the hint from the `.info` as mkhint does, without phantom deps (see the + facts), then applies the version when given. No `.bak` (a stale + one is removed); revert means delete. +diff --git c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md +index 7c7b254..8a6844d 100644 +--- c/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md ++++ i/docs/superpowers/specs/2026-10-20-sb-builder-ui-design.md +@@ -70,7 +70,7 @@ Disabled reasons (tooltips): + - Delete hint: "none of the ticked packages has a hint". + - Show build log: "select a result with a log". + - Run it: "select a finished preview". +-- Kill: "no job running". ++- Kill: "select a running job". + - Check upstream while running: "checking…". + + Update all, Publish, Pull overlay and Check shadowing are always enabled +@@ -120,7 +120,8 @@ dialog, Build these -> preview dialog, Run it. No tab switch. + + - Starting a job no longer switches to Jobs; the user stays on the tab. + - While a job runs, the Jobs tab shows a running icon (`system-run`) and +- accent-colored text (palette highlight). ++ reads "Jobs (running)": the tab style sheet's text color would override a ++ per-tab color. + - When it ends, the icon becomes the end status (`✔` ok, `✘` failed, + killed, lost) and stays until the Jobs tab is shown, then clears. + - The status bar job button stays. +``` + +- [ ] **Step 2: Commit and push.** + +```bash +git add README.md AGENTS.md docs/superpowers/specs +git commit -m "UI guidelines in AGENTS.md; README for the new frame + +Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" +git push +``` + +### Task 4: deploy (ask first) + +- [ ] `./install.sh --remote`, expected `{"version": "0.6.0", ...}`. +- [ ] The user restarts sb-builder and runs the hugo-style flow: Hints, Check upstream, Update selected, Build these, Run it, all without switching tab. |
