Improve TUI startup and keep plugin toggles responsive

Auto-open the plugin table when only one installation exists, run enable and
disable work off the UI thread with immediate status feedback, and skip the
back action for single-instance setups.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pleb
2026-07-01 22:56:52 -07:00
parent c86e51b85a
commit b75d8ccc57
3 changed files with 85 additions and 35 deletions
+2 -1
View File
@@ -128,7 +128,8 @@ The menu reads `plugin-helper.local.toml` when present, shows each discovered
Beat Saber install with its resolved state directory, and lets you toggle Beat Saber install with its resolved state directory, and lets you toggle
managed plugins with arrow keys and Space. In the plugin table, use `d` to managed plugins with arrow keys and Space. In the plugin table, use `d` to
disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all disable all currently enabled managed plugins (except `bsipa`) and `e` to enable all
currently disabled managed plugins. Re-enabling a plugin auto-bootstraps BSIPA when currently disabled managed plugins. With a single Beat Saber installation, the
menu opens the plugin table directly. Re-enabling a plugin auto-bootstraps BSIPA when
needed. needed.
The individual subcommands are mostly for automation and debugging. If you use The individual subcommands are mostly for automation and debugging. If you use
+66 -29
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -64,6 +65,7 @@ class PluginHelperTui(App[int]):
self.selected_installation: InstallationChoice | None = None self.selected_installation: InstallationChoice | None = None
self.plugin_rows: list[dict[str, Any]] = [] self.plugin_rows: list[dict[str, Any]] = []
self.status_message = "" self.status_message = ""
self._busy = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(show_clock=False) yield Header(show_clock=False)
@@ -75,6 +77,10 @@ class PluginHelperTui(App[int]):
def on_mount(self) -> None: def on_mount(self) -> None:
table = self.query_one(DataTable) table = self.query_one(DataTable)
table.cursor_type = "row" table.cursor_type = "row"
if len(self.choices) == 1:
self.selected_installation = self.choices[0]
self._show_plugins()
return
self._show_installations() self._show_installations()
def action_select(self) -> None: def action_select(self) -> None:
@@ -88,16 +94,20 @@ class PluginHelperTui(App[int]):
def action_back(self) -> None: def action_back(self) -> None:
if self.mode == "plugins": if self.mode == "plugins":
if len(self.choices) == 1:
return
self._show_installations() self._show_installations()
def action_refresh(self) -> None: def action_refresh(self) -> None:
if self._busy:
return
if self.mode == "plugins": if self.mode == "plugins":
self._show_plugins() self._show_plugins()
else: else:
self._show_installations() self._show_installations()
def action_toggle_plugin(self) -> None: async def action_toggle_plugin(self) -> None:
if self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
index = self._cursor_index(len(self.plugin_rows)) index = self._cursor_index(len(self.plugin_rows))
if index is None: if index is None:
@@ -105,27 +115,32 @@ class PluginHelperTui(App[int]):
plugin = self.plugin_rows[index] plugin = self.plugin_rows[index]
plugin_id = plugin["id"] plugin_id = plugin["id"]
target = self.selected_installation target = self.selected_installation
self._busy = True
try: try:
if plugin["status"] == "enabled": if plugin["status"] == "enabled":
result = disable_plugin( self._set_status(f"Disabling {plugin_id}...")
result = await asyncio.to_thread(
disable_plugin,
target.instance_name, target.instance_name,
target.instance_path, target.instance_path,
target.state_root, target.state_root,
plugin_id, plugin_id,
force=False, False,
) )
if not result["stateUpdated"]: if not result["stateUpdated"]:
self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}") self._set_status(f"Could not disable {plugin_id}: {self._format_skipped(result['skipped'])}")
return return
self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.") self._set_status(f"Disabled {plugin_id}; removed {len(result['removed'])} files.")
elif plugin["status"] == "disabled": elif plugin["status"] == "disabled":
result = enable_disabled_plugin( self._set_status(f"Enabling {plugin_id}...")
result = await asyncio.to_thread(
enable_disabled_plugin,
instance=target.instance_name, instance=target.instance_name,
instance_path=target.instance_path, instance_path=target.instance_path,
state_root=target.state_root, state_root=target.state_root,
plugin_id=plugin_id, plugin_id=plugin_id,
repo=self.repo_root, repo=self.repo_root,
progress=lambda message: self._set_status(f"Bootstrapping: {message}"), progress=self._bootstrap_progress,
) )
self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.") self._set_status(f"Enabled {plugin_id}; applied {len(result['applied'])} files.")
else: else:
@@ -134,10 +149,12 @@ class PluginHelperTui(App[int]):
except Exception as exc: except Exception as exc:
self._set_status(f"Could not toggle {plugin_id}: {exc}") self._set_status(f"Could not toggle {plugin_id}: {exc}")
return return
finally:
self._busy = False
self._show_plugins(preserve_status=True) self._show_plugins(preserve_status=True)
def action_disable_all_plugins(self) -> None: async def action_disable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
target = self.selected_installation target = self.selected_installation
enabled = [ enabled = [
@@ -148,32 +165,41 @@ class PluginHelperTui(App[int]):
skipped_bsipa = any( skipped_bsipa = any(
plugin["status"] == "enabled" and plugin["id"] == BSIPA_PLUGIN_ID for plugin in self.plugin_rows plugin["status"] == "enabled" and plugin["id"] == BSIPA_PLUGIN_ID for plugin in self.plugin_rows
) )
if not enabled:
self._set_status("No enabled plugins to disable.")
return
self._busy = True
self._set_status(f"Disabling {len(enabled)} plugins...")
changed = 0 changed = 0
errors: list[str] = [] errors: list[str] = []
for plugin in enabled: try:
plugin_id = plugin["id"] for plugin in enabled:
try: plugin_id = plugin["id"]
result = disable_plugin( try:
target.instance_name, result = await asyncio.to_thread(
target.instance_path, disable_plugin,
target.state_root, target.instance_name,
plugin_id, target.instance_path,
force=False, target.state_root,
) plugin_id,
if result["stateUpdated"]: False,
changed += 1 )
else: if result["stateUpdated"]:
errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}") changed += 1
except Exception as exc: else:
errors.append(f"{plugin_id}: {exc}") errors.append(f"{plugin_id}: {self._format_skipped(result['skipped'])}")
except Exception as exc:
errors.append(f"{plugin_id}: {exc}")
finally:
self._busy = False
if skipped_bsipa and not errors: if skipped_bsipa and not errors:
self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).") self._set_status(f"Disabled {changed} plugins (bsipa kept enabled).")
else: else:
self._set_bulk_status("Disabled", changed, errors) self._set_bulk_status("Disabled", changed, errors)
self._show_plugins(preserve_status=True) self._show_plugins(preserve_status=True)
def action_enable_all_plugins(self) -> None: async def action_enable_all_plugins(self) -> None:
if self.mode != "plugins" or self.selected_installation is None: if self._busy or self.mode != "plugins" or self.selected_installation is None:
return return
target = self.selected_installation target = self.selected_installation
disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"] disabled = [plugin for plugin in self.plugin_rows if plugin["status"] == "disabled"]
@@ -181,23 +207,31 @@ class PluginHelperTui(App[int]):
self._set_status("No disabled plugins to enable.") self._set_status("No disabled plugins to enable.")
return return
plugin_ids = [plugin["id"] for plugin in disabled] plugin_ids = [plugin["id"] for plugin in disabled]
self._busy = True
self._set_status(f"Enabling {len(plugin_ids)} plugins...")
try: try:
result = enable_disabled_plugins( result = await asyncio.to_thread(
enable_disabled_plugins,
instance=target.instance_name, instance=target.instance_name,
instance_path=target.instance_path, instance_path=target.instance_path,
state_root=target.state_root, state_root=target.state_root,
plugin_ids=plugin_ids, plugin_ids=plugin_ids,
repo=self.repo_root, repo=self.repo_root,
progress=lambda message: self._set_status(f"Bootstrapping: {message}"), progress=self._bootstrap_progress,
) )
except Exception as exc: except Exception as exc:
self._set_status(f"Could not enable plugins: {exc}") self._set_status(f"Could not enable plugins: {exc}")
return return
finally:
self._busy = False
changed = len(result["enabled"]) changed = len(result["enabled"])
errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]] errors = [f"{item['plugin']}: {item['error']}" for item in result["errors"]]
self._set_bulk_status("Enabled", changed, errors) self._set_bulk_status("Enabled", changed, errors)
self._show_plugins(preserve_status=True) self._show_plugins(preserve_status=True)
def _bootstrap_progress(self, message: str) -> None:
self.call_from_thread(self._set_status, f"Bootstrapping: {message}")
def _show_installations(self) -> None: def _show_installations(self) -> None:
self.mode = "installations" self.mode = "installations"
self.plugin_rows = [] self.plugin_rows = []
@@ -253,7 +287,10 @@ class PluginHelperTui(App[int]):
) )
if not preserve_status: if not preserve_status:
if self.plugin_rows: if self.plugin_rows:
self._set_status("Space toggles selected. d disables all. e enables all. b returns to installations.") back_hint = "" if len(self.choices) == 1 else " b returns to installations."
self._set_status(
f"Space toggles selected. d disables all. e enables all.{back_hint}"
)
else: else:
self._set_status("No managed plugins recorded for this installation.") self._set_status("No managed plugins recorded for this installation.")
+17 -5
View File
@@ -1496,16 +1496,27 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(table.row_count, 2) self.assertEqual(table.row_count, 2)
self.assertEqual(app.mode, "installations") self.assertEqual(app.mode, "installations")
async def test_single_instance_skips_installation_picker(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
app, _instance, _state = _make_tui_fixture(Path(tmp))
async with app.run_test():
self.assertEqual(app.mode, "plugins")
self.assertEqual(app.selected_installation, app.choices[0])
table = app.query_one(DataTable)
self.assertEqual(table.row_count, 1)
async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None: async def test_space_disables_enabled_plugin_without_id_prompt(self) -> None:
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
app, instance, state = _make_tui_fixture(Path(tmp)) app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("enter") self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "enabled") self.assertEqual(app.plugin_rows[0]["status"], "enabled")
table = app.query_one(DataTable) table = app.query_one(DataTable)
self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True)) self.assertEqual(table.get_cell_at(Coordinate(0, 0)), Text("[x]", no_wrap=True))
await pilot.press("space") await pilot.press("space")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists()) self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8")
@@ -1517,9 +1528,10 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("enter") self.assertEqual(app.mode, "plugins")
self.assertEqual(app.plugin_rows[0]["status"], "disabled") self.assertEqual(app.plugin_rows[0]["status"], "disabled")
await pilot.press("space") await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8")
@@ -1531,8 +1543,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
app, instance, state = _make_tui_fixture(Path(tmp)) app, instance, state = _make_tui_fixture(Path(tmp))
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("d") await pilot.press("d")
await pilot.pause()
self.assertFalse((instance / "Plugins" / "Example.dll").exists()) self.assertFalse((instance / "Plugins" / "Example.dll").exists())
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8")
@@ -1545,8 +1557,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
app, instance, state = _make_tui_fixture(Path(tmp), disabled=True) app, instance, state = _make_tui_fixture(Path(tmp), disabled=True)
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("e") await pilot.press("e")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"managed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8")
@@ -1559,8 +1571,8 @@ class PluginHelperTuiTests(unittest.IsolatedAsyncioTestCase):
app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True) app, instance, state = _make_tui_fixture(Path(tmp), hash_mismatch=True)
async with app.run_test() as pilot: async with app.run_test() as pilot:
await pilot.press("enter")
await pilot.press("space") await pilot.press("space")
await pilot.pause()
self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll") self.assertEqual((instance / "Plugins" / "Example.dll").read_bytes(), b"changed dll")
updated = load_installed_state(state, "1.40.8") updated = load_installed_state(state, "1.40.8")