muxplex-deck 0.6.1__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/PKG-INFO +1 -1
  2. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/pyproject.toml +1 -1
  3. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/cli.py +321 -99
  4. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/layout.py +52 -17
  5. muxplex_deck-0.7.0/src/muxplex_deck/report.py +416 -0
  6. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/service.py +20 -8
  7. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/statusfile.py +1 -1
  8. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/README.md +0 -0
  9. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/__init__.py +0 -0
  10. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/__main__.py +0 -0
  11. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/capabilities.py +0 -0
  12. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/events.py +0 -0
  13. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/main.py +0 -0
  14. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/deck_probe/rendering.py +0 -0
  15. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/__init__.py +0 -0
  16. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/__main__.py +0 -0
  17. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/LICENSE-bsd.txt +0 -0
  18. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/VERSION +0 -0
  19. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/_vendor/hidapi/x64/hidapi.dll +0 -0
  20. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/attention.py +0 -0
  21. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/config.py +0 -0
  22. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/device.py +0 -0
  23. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/device_real.py +0 -0
  24. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/emulator.py +0 -0
  25. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/focus.py +0 -0
  26. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/hidapi_win.py +0 -0
  27. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/hidhelp.py +0 -0
  28. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/init_wizard.py +0 -0
  29. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/interaction.py +0 -0
  30. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/main.py +0 -0
  31. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/rendering.py +0 -0
  32. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/usbnode.py +0 -0
  33. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/views.py +0 -0
  34. {muxplex_deck-0.6.1 → muxplex_deck-0.7.0}/src/muxplex_deck/wsl.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: muxplex-deck
3
- Version: 0.6.1
3
+ Version: 0.7.0
4
4
  Summary: Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)
5
5
  Author: Amplifier
6
6
  Author-email: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "muxplex-deck"
3
- version = "0.6.1"
3
+ version = "0.7.0"
4
4
  description = "Hardware probe/diagnostic app for the Elgato Stream Deck+ (seed of the muxplex sidecar)"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -812,15 +812,118 @@ def print_check(status: str, message: str) -> None:
812
812
  print(f"{prefix}{line}")
813
813
 
814
814
 
815
- def doctor(config_path: str | None = None) -> int:
815
+ def _status_glyph(status: str) -> str:
816
+ """Map a check helper's ("ok"|"warn"|"fail") status to a report glyph.
817
+
818
+ "ok" -> fine. "fail" (a definitive rejection) and "warn" (almost always
819
+ paired with a "run: ..." remediation already in the message) both ->
820
+ act-now. Checks that are genuinely blocked on an upstream item (e.g.
821
+ server/service before config exists) are never routed through this
822
+ mapper -- callers construct those `Check`s directly with
823
+ `report.BLOCKED` instead.
824
+ """
825
+ from . import report
826
+
827
+ return report.FINE if status == "ok" else report.ACT
828
+
829
+
830
+ def _hid_tail(hid_message: str) -> str:
831
+ """Short phrase summarizing a successful HID-open, for the merged device line."""
832
+ text = hid_message
833
+ text = text.removeprefix("HID: ")
834
+ if text.startswith("device opened successfully"):
835
+ return "HID opens"
836
+ if text.startswith("device in use by the muxplex-deck service"):
837
+ return "HID in use by service"
838
+ return text if text.startswith("HID") else f"HID: {text}"
839
+
840
+
841
+ def _device_hid_checks(
842
+ deck_status: str, deck_message: str, hid_status: str, hid_message: str
843
+ ) -> list[Any]:
844
+ """Merge device-detection + HID-open into one line when they agree.
845
+
846
+ When both share a glyph there's nothing to hide by combining them --
847
+ one readable "device" line beats two boring ones. When they diverge
848
+ (e.g. the device is visible on Windows but WSL can't open it yet),
849
+ collapsing to a single worst-member line would silently drop whichever
850
+ fact ACTION needs to reference (like a busid) -- so they render as two
851
+ independent, dependency-ordered lines instead: "device" (is it
852
+ physically present) then "hid" (can this process open it).
853
+ """
854
+ from . import report
855
+
856
+ deck_glyph = _status_glyph(deck_status)
857
+ hid_glyph = _status_glyph(hid_status)
858
+ if deck_glyph == hid_glyph:
859
+ tail = _hid_tail(hid_message)
860
+ value = f"{deck_message}, {tail}" if tail else deck_message
861
+ return [report.Check("device", deck_glyph, value)]
862
+ return [
863
+ report.Check("device", deck_glyph, deck_message),
864
+ report.Check("hid", hid_glyph, hid_message),
865
+ ]
866
+
867
+
868
+ def _doctor_decision(check: Any) -> Any:
869
+ """Build the ACTION decision for the first act-now check, in dependency order."""
870
+ from . import report
871
+
872
+ if check.subject == "config":
873
+ return report.Decision(
874
+ commands=["muxplex-deck init"],
875
+ prose=(
876
+ "Creates the config, fetches and fingerprints the server CA, "
877
+ "stores the federation key, and offers to install the service."
878
+ ),
879
+ )
880
+ command = report.extract_run_command(check.value)
881
+ if command:
882
+ return report.Decision(commands=[command])
883
+ return report.Decision(commands=[], prose=check.value)
884
+
885
+
886
+ def _build_doctor_action(collapsed: list[Any]) -> list[str] | None:
887
+ """The ACTION band: the first act-now item's decision, plus an overflow
888
+
889
+ note when more than one independent act-now item exists (see
890
+ report.Action.overflow_note) -- never dump every fix at once.
891
+ """
892
+ from . import report
893
+
894
+ act_items = [c for c in collapsed if c.glyph == report.ACT]
895
+ if not act_items:
896
+ return None
897
+ overflow = None
898
+ if len(act_items) > 1:
899
+ overflow = f"{len(act_items) - 1} more after this -- rerun doctor."
900
+ action = report.Action(
901
+ decision=_doctor_decision(act_items[0]), overflow_note=overflow
902
+ )
903
+ return report.render_action(action)
904
+
905
+
906
+ def doctor(config_path: str | None = None, *, show_all: bool = False) -> int:
816
907
  """Run diagnostic checks and report system status. Always returns 0 (informational)."""
817
- print("\nmuxplex-deck doctor\n")
908
+ from . import hidhelp, report
909
+
910
+ utf8 = report.utf8_capable()
818
911
 
819
- checks: list[tuple[str, str]] = []
820
- checks.append(check_python_version())
821
- checks.append(check_install_and_update())
822
- checks.append(check_config_file(config_path))
912
+ py_status, py_message = check_python_version()
913
+ inst_status, inst_message = check_install_and_update()
914
+ env_members = [
915
+ report.Check("python", _status_glyph(py_status), py_message),
916
+ report.Check("install", _status_glyph(inst_status), inst_message),
917
+ ]
918
+ hidapi_check = check_hidapi_dll() # Windows-only; None everywhere else.
919
+ if hidapi_check is not None:
920
+ hidapi_status, hidapi_message = hidapi_check
921
+ env_members.append(
922
+ report.Check("hidapi", _status_glyph(hidapi_status), hidapi_message)
923
+ )
924
+ environment_group = report.Group("environment", env_members)
823
925
 
926
+ cfg_status, cfg_message = check_config_file(config_path)
824
927
  try:
825
928
  cfg = config_mod.load_config(config_path)
826
929
  except ConfigError:
@@ -828,29 +931,44 @@ def doctor(config_path: str | None = None) -> int:
828
931
 
829
932
  raw = config_mod.load_raw_config(config_path)
830
933
  key_file = config_mod._expand(raw.get("key_file", config_mod.DEFAULT_KEY_FILE))
831
- checks.append(check_federation_key(key_file))
934
+ key_status, key_message = check_federation_key(key_file)
832
935
 
833
936
  ca_file = cfg.ca_file if cfg is not None else None
834
937
  if cfg is None and raw.get("ca_file"):
835
938
  ca_file = config_mod._expand(raw["ca_file"])
836
- checks.append(check_ca_file(ca_file))
939
+ ca_status, ca_message = check_ca_file(ca_file)
940
+
941
+ config_group = report.Group(
942
+ "config",
943
+ [
944
+ report.Check("file", _status_glyph(cfg_status), cfg_message),
945
+ report.Check("key", _status_glyph(key_status), key_message),
946
+ report.Check("ca", _status_glyph(ca_status), ca_message),
947
+ ],
948
+ )
949
+ config_created = cfg_status == "ok"
837
950
 
838
- # Windows-only; None (skipped, not appended) on every other platform.
839
- hidapi_check = check_hidapi_dll()
840
- if hidapi_check is not None:
841
- checks.append(hidapi_check)
951
+ items: list[Any] = [environment_group, config_group]
842
952
 
843
953
  # Environment guidance (WSL/usbipd/udev-liveness) BEFORE the device
844
954
  # checks -- it explains why the next line is about to warn. Returns []
845
955
  # on a healthy platform (macOS, or native Linux with udev running), so
846
956
  # this adds no output there -- see hidhelp.explain_environment().
847
- from . import hidhelp
848
-
849
957
  env_guidances = hidhelp.explain_environment()
850
958
  for guidance in env_guidances:
851
- checks.append((guidance.status, guidance.message))
959
+ items.append(
960
+ report.Check("device", _status_glyph(guidance.status), guidance.message)
961
+ )
852
962
  env_states = {g.state for g in env_guidances}
853
963
 
964
+ if not config_created:
965
+ # Server/service structurally need server_url/config -- blocked,
966
+ # not independently evaluated. Device/HID don't depend on config
967
+ # at all (a bare USB probe works with no config file), so they are
968
+ # still evaluated for real below.
969
+ items.append(report.Check("server", report.BLOCKED, "waiting on config"))
970
+ items.append(report.Check("service", report.BLOCKED, "waiting on config"))
971
+
854
972
  if "W7" in env_states:
855
973
  # The W7 guidance just appended above already says the device is
856
974
  # attached but can't be opened, plus the proven per-attach `chown`
@@ -874,22 +992,27 @@ def doctor(config_path: str | None = None) -> int:
874
992
  # yet) -- the generic "check the cable" guidance would
875
993
  # flatly contradict it (see bug report: located @ BUSID 1-4,
876
994
  # immediately followed by "check your cable").
877
- deck_message = (
878
- "Stream Deck: not detected on this OS yet -- see the WSL "
879
- "guidance above."
880
- )
881
- checks.append((deck_status, deck_message))
882
- checks.append(check_hid_openable())
995
+ deck_message = "not detected on this OS yet -- see the WSL guidance above."
996
+ hid_status, hid_message = check_hid_openable()
997
+ items.extend(
998
+ _device_hid_checks(deck_status, deck_message, hid_status, hid_message)
999
+ )
883
1000
 
884
- server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
885
- checks.append(check_server_reachable(server_url, ca_file))
1001
+ if config_created:
1002
+ server_url = cfg.server_url if cfg is not None else raw.get("server_url", "")
1003
+ srv_status, srv_message = check_server_reachable(server_url, ca_file)
1004
+ items.append(report.Check("server", _status_glyph(srv_status), srv_message))
886
1005
 
887
- checks.append(check_service_status())
1006
+ svc_status, svc_message = check_service_status()
1007
+ items.append(report.Check("service", _status_glyph(svc_status), svc_message))
888
1008
 
889
- for status, message in checks:
890
- print_check(status, message)
1009
+ collapsed = report.collapsed_checks(items)
1010
+ action_count = report.count_actions([c.glyph for c in collapsed])
1011
+ verdict = report.verdict_readiness(action_count)
1012
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1013
+ action_lines = _build_doctor_action(collapsed)
891
1014
 
892
- print()
1015
+ sys.stdout.write(report.render(verdict, state_lines, action_lines))
893
1016
  return 0
894
1017
 
895
1018
 
@@ -908,48 +1031,62 @@ def doctor(config_path: str | None = None) -> int:
908
1031
  _STATUS_STALE_THRESHOLD_SECONDS = 15.0
909
1032
 
910
1033
 
911
- def _format_device_line(device: dict[str, Any]) -> tuple[str, str]:
1034
+ def _status_device_item(device: dict[str, Any]) -> Any:
1035
+ """Build the "device" Check from the sidecar's published status snapshot.
1036
+
1037
+ "-" is the correct rendering for "I could not determine this" -- during
1038
+ a restart race the caller returns before this is ever called (see the
1039
+ pid-freshness guard in `status()`), so this function only ever runs
1040
+ when the snapshot is genuinely current.
1041
+ """
1042
+ from . import report
1043
+
912
1044
  if not device.get("connected"):
913
- return "warn", "Device: not connected"
1045
+ return report.Check("device", report.ACT, "not connected")
914
1046
  caps = device.get("capabilities") or {}
915
1047
  hint = device.get("hint")
916
- if not caps and hint:
917
- return "warn", "Device: connected (capabilities unavailable)"
918
1048
  if not caps:
919
- return "ok", "Device: connected (capabilities unavailable)"
1049
+ glyph = report.ACT if hint else report.FINE
1050
+ return report.Check("device", glyph, "connected (capabilities unavailable)")
920
1051
  touchscreen = "yes" if caps.get("has_touchscreen") else "no"
921
- return "ok", (
922
- f"Device: {caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
1052
+ value = (
1053
+ f"{caps.get('model', '?')} -- {caps.get('key_count', '?')} keys "
923
1054
  f"({caps.get('key_rows', '?')}x{caps.get('key_cols', '?')}), "
924
1055
  f"{caps.get('dial_count', '?')} dials, touchscreen={touchscreen}"
925
1056
  )
1057
+ return report.Check("device", report.FINE, value)
926
1058
 
927
1059
 
928
- def _format_server_line(server: dict[str, Any]) -> tuple[str, str]:
1060
+ def _status_server_item(server: dict[str, Any]) -> Any:
1061
+ from . import report
1062
+
929
1063
  url = server.get("url") or "(not configured)"
930
1064
  if server.get("connected"):
931
- return "ok", f"Server: {url} (reachable)"
1065
+ return report.Check("server", report.FINE, f"{url} (reachable)")
932
1066
  err = server.get("last_error")
933
- message = f"Server: {url} (unreachable)"
1067
+ value = f"{url} (unreachable)"
934
1068
  if err:
935
- message += f" -- {err}"
936
- return "warn", message
1069
+ value += f" -- {err}"
1070
+ return report.Check("server", report.ACT, value)
937
1071
 
938
1072
 
939
- def _format_state_line(state: dict[str, Any]) -> str:
940
- session = state.get("active_session") or "none"
941
- view = state.get("active_view") or "all"
1073
+ def _status_view_value(state: dict[str, Any]) -> str:
1074
+ view_name = state.get("active_view") or "all"
942
1075
  page = state.get("page")
943
- page_text = str(page) if page is not None else "-"
944
- return f"Active session: {session} | view: {view} | page: {page_text}"
1076
+ return f"{view_name} (page {page})" if page is not None else view_name
945
1077
 
946
1078
 
947
- def _print_direct_probe(config_path: str | None) -> None:
1079
+ def _direct_probe_item(config_path: str | None) -> Any:
948
1080
  """Fallback when the service isn't running -- nothing holds the device."""
949
- print_check(*check_deck_detected(config_path))
1081
+ from . import report
1082
+
1083
+ deck_status, deck_message = check_deck_detected(config_path)
1084
+ return report.Check("device", _status_glyph(deck_status), deck_message)
950
1085
 
951
1086
 
952
- def status(config_path: str | None = None, *, as_json: bool = False) -> int:
1087
+ def status(
1088
+ config_path: str | None = None, *, as_json: bool = False, show_all: bool = False
1089
+ ) -> int:
953
1090
  """Print the sidecar's hardware + connection status.
954
1091
 
955
1092
  Reads the status file the running sidecar publishes (see
@@ -961,6 +1098,7 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
961
1098
  fall back to a direct probe -- this keeps `status` useful even before
962
1099
  the service has ever been installed.
963
1100
  """
1101
+ from . import report
964
1102
  from .service import service_is_active, service_main_pid
965
1103
  from .statusfile import read_status
966
1104
 
@@ -972,36 +1110,45 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
972
1110
  print(json.dumps(payload, indent=2))
973
1111
  return 0 if data is not None else 1
974
1112
 
975
- print("\nmuxplex-deck status\n")
976
- print_check(
977
- "ok" if running else "warn",
978
- f"Service: {'running' if running else 'not running'}",
979
- )
1113
+ utf8 = report.utf8_capable()
980
1114
 
981
1115
  if not running:
982
- if data is None:
983
- print_check(
984
- "warn",
985
- "No status file found -- probing the device directly instead "
986
- "(safe: nothing holds it while the service is stopped).",
987
- )
988
- else:
989
- print_check(
990
- "warn",
991
- "Service not running -- probing the device directly instead "
992
- "of trusting the (possibly stale) status file.",
993
- )
994
- _print_direct_probe(config_path)
995
- print()
1116
+ probe_note = (
1117
+ "no status file -- probing the device directly instead (safe: "
1118
+ "nothing holds it while the service is stopped)"
1119
+ if data is None
1120
+ else "not trusting the (possibly stale) status file -- probing "
1121
+ "the device directly instead"
1122
+ )
1123
+ items: list[Any] = [
1124
+ report.Check("service", report.BLOCKED, "not running"),
1125
+ report.Check("probe", report.BLOCKED, probe_note),
1126
+ _direct_probe_item(config_path),
1127
+ ]
1128
+ collapsed = report.collapsed_checks(items)
1129
+ action_count = report.count_actions([c.glyph for c in collapsed])
1130
+ verdict = (
1131
+ "Not connected -- service not running."
1132
+ if action_count == 0
1133
+ else report.verdict_readiness(action_count)
1134
+ )
1135
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1136
+ sys.stdout.write(report.render(verdict, state_lines, None))
996
1137
  return 0
997
1138
 
998
1139
  if data is None:
999
- print_check(
1000
- "unknown",
1001
- "No status file found even though the service is running -- it "
1002
- "may have just started. Try: muxplex-deck service logs",
1140
+ items = [
1141
+ report.Check(
1142
+ "status",
1143
+ report.BLOCKED,
1144
+ "no status file yet -- the sidecar may have just started. "
1145
+ "Try: muxplex-deck service logs",
1146
+ )
1147
+ ]
1148
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1149
+ sys.stdout.write(
1150
+ report.render("Not connected -- starting up.", state_lines, None)
1003
1151
  )
1004
- print()
1005
1152
  return 0
1006
1153
 
1007
1154
  # Is this snapshot from the process running RIGHT NOW, or from a
@@ -1024,40 +1171,94 @@ def status(config_path: str | None = None, *, as_json: bool = False) -> int:
1024
1171
  ) <= _STATUS_STALE_THRESHOLD_SECONDS
1025
1172
 
1026
1173
  if not is_current:
1027
- print_check(
1028
- "unknown",
1029
- f"Status not yet available for the running process (pid "
1030
- f"{current_pid if current_pid is not None else '?'}) -- the "
1031
- f"published status is from a previous run (pid {recorded_pid}). "
1032
- "This is expected right after (re)starting; try again in a "
1033
- "moment, or: muxplex-deck service logs",
1174
+ # "-" (BLOCKED), never "!" (act-now): we did not actually observe
1175
+ # this instant, so nothing here has earned a verdict yet -- see the
1176
+ # restart-race incident this guards against (AGENTS.md).
1177
+ items = [
1178
+ report.Check(
1179
+ "status",
1180
+ report.BLOCKED,
1181
+ f"not yet available for the running process (pid "
1182
+ f"{current_pid if current_pid is not None else '?'}) -- the "
1183
+ f"published status is from a previous run (pid {recorded_pid}). "
1184
+ "This is expected right after (re)starting; try again in a "
1185
+ "moment, or: muxplex-deck service logs",
1186
+ )
1187
+ ]
1188
+ state_lines = report.render_items(items, show_all=show_all, utf8=utf8)
1189
+ sys.stdout.write(
1190
+ report.render("Not connected -- previous run's data.", state_lines, None)
1034
1191
  )
1035
- print()
1036
1192
  return 0
1037
1193
 
1038
1194
  age = time.time() - data.get("updated_at", 0)
1039
- if age > _STATUS_STALE_THRESHOLD_SECONDS:
1040
- print_check(
1041
- "warn",
1042
- f"Status file is stale (last updated {age:.0f}s ago) -- the "
1043
- "sidecar may be stuck. Try: muxplex-deck service logs",
1044
- )
1045
- else:
1046
- print_check("ok", f"Status updated {age:.0f}s ago (pid {data.get('pid', '?')})")
1195
+ stale = age > _STATUS_STALE_THRESHOLD_SECONDS
1196
+ device_item = _status_device_item(data.get("device", {}))
1197
+ server_item = _status_server_item(data.get("server", {}))
1198
+ state = data.get("state", {})
1199
+
1200
+ session_readout = report.Readout("session", state.get("active_session") or "none")
1201
+ view_readout = report.Readout("view", _status_view_value(state))
1202
+ pid_readout = report.Readout(
1203
+ "pid", f"{data.get('pid', '?')} (updated {age:.0f}s ago)"
1204
+ )
1047
1205
 
1048
- device_data = data.get("device", {})
1049
- print_check(*_format_device_line(device_data))
1050
- hint = device_data.get("hint")
1051
- if hint and not device_data.get("connected"):
1206
+ all_fine = (
1207
+ not stale
1208
+ and device_item.glyph == report.FINE
1209
+ and server_item.glyph == report.FINE
1210
+ )
1211
+
1212
+ if all_fine:
1213
+ readouts = [
1214
+ session_readout,
1215
+ view_readout,
1216
+ report.Readout("device", device_item.value),
1217
+ report.Readout("server", server_item.value),
1218
+ pid_readout,
1219
+ ]
1220
+ state_lines = report.render_readouts(readouts)
1221
+ sys.stdout.write(report.render("Running.", state_lines, None))
1222
+ return 0
1223
+
1224
+ lines: list[str] = [
1225
+ report.format_readout_line(session_readout.name, session_readout.value),
1226
+ report.format_readout_line(view_readout.name, view_readout.value),
1227
+ report.format_check_line(
1228
+ device_item.glyph, device_item.subject, device_item.value, utf8=utf8
1229
+ ),
1230
+ ]
1231
+ hint = data.get("device", {}).get("hint")
1232
+ if hint and device_item.glyph != report.FINE:
1052
1233
  # Populated by the sidecar's open-failure branch (see main.py /
1053
1234
  # hidhelp.explain_open_failure) -- this is what turns a stale
1054
1235
  # status file into the primary teaching surface instead of a
1055
1236
  # bare "not connected" with no explanation.
1056
- print_check("warn", hint)
1057
- print_check(*_format_server_line(data.get("server", {})))
1058
- print_check("ok", _format_state_line(data.get("state", {})))
1059
-
1060
- print()
1237
+ lines.append(report.format_check_line(report.ACT, "hint", hint, utf8=utf8))
1238
+ lines.append(
1239
+ report.format_check_line(
1240
+ server_item.glyph, server_item.subject, server_item.value, utf8=utf8
1241
+ )
1242
+ )
1243
+ if stale:
1244
+ lines.append(
1245
+ report.format_check_line(
1246
+ report.ACT,
1247
+ "status",
1248
+ f"stale (last updated {age:.0f}s ago) -- the sidecar may be "
1249
+ "stuck. Try: muxplex-deck service logs",
1250
+ utf8=utf8,
1251
+ )
1252
+ )
1253
+ lines.append(report.format_readout_line(pid_readout.name, pid_readout.value))
1254
+
1255
+ glyphs = [device_item.glyph, server_item.glyph]
1256
+ if hint and device_item.glyph != report.FINE:
1257
+ glyphs.append(report.ACT)
1258
+ if stale:
1259
+ glyphs.append(report.ACT)
1260
+ verdict = report.verdict_readiness(report.count_actions(glyphs))
1261
+ sys.stdout.write(report.render(verdict, lines, None))
1061
1262
  return 0
1062
1263
 
1063
1264
 
@@ -1428,7 +1629,17 @@ def main() -> None:
1428
1629
 
1429
1630
  sub.add_parser("version", help="Show the muxplex-deck version")
1430
1631
 
1431
- sub.add_parser("doctor", help="Check dependencies and system status")
1632
+ doctor_parser = sub.add_parser(
1633
+ "doctor", help="Check dependencies and system status"
1634
+ )
1635
+ doctor_parser.add_argument(
1636
+ "--all",
1637
+ "--verbose",
1638
+ dest="show_all",
1639
+ action="store_true",
1640
+ help="Show every underlying check (VID:PID, full paths, etc.) instead "
1641
+ "of the collapsed summary -- a strict superset, never a different answer",
1642
+ )
1432
1643
 
1433
1644
  status_parser = sub.add_parser(
1434
1645
  "status", help="Show connected hardware + connection state"
@@ -1436,6 +1647,13 @@ def main() -> None:
1436
1647
  status_parser.add_argument(
1437
1648
  "--json", action="store_true", help="Emit raw status as JSON"
1438
1649
  )
1650
+ status_parser.add_argument(
1651
+ "--all",
1652
+ "--verbose",
1653
+ dest="show_all",
1654
+ action="store_true",
1655
+ help="Show every underlying check instead of the collapsed summary",
1656
+ )
1439
1657
 
1440
1658
  update_parser = sub.add_parser(
1441
1659
  "update",
@@ -1511,10 +1729,14 @@ def main() -> None:
1511
1729
  if args.command == "version":
1512
1730
  print_version()
1513
1731
  elif args.command == "doctor":
1514
- doctor(getattr(args, "config", None))
1732
+ doctor(getattr(args, "config", None), show_all=getattr(args, "show_all", False))
1515
1733
  elif args.command == "status":
1516
1734
  sys.exit(
1517
- status(getattr(args, "config", None), as_json=getattr(args, "json", False))
1735
+ status(
1736
+ getattr(args, "config", None),
1737
+ as_json=getattr(args, "json", False),
1738
+ show_all=getattr(args, "show_all", False),
1739
+ )
1518
1740
  )
1519
1741
  elif args.command in ("update", "upgrade"):
1520
1742
  update(force=getattr(args, "force", False))
@@ -13,18 +13,34 @@ Two layout modes:
13
13
  Deck+). Every LCD key is a session tile; dial 0 cycles views, dial 1
14
14
  pages, and the touch strip carries the server/view/status headline.
15
15
  This is the pre-existing Stream Deck+ behavior, unchanged.
16
- - REDUCED -- everything else (Original/MK2 3x5, XL 4x8, Mini 2x3, ...).
16
+ - REDUCED -- everything else (Original/MK2 3x5, XL 4x8, Mini 3x2, ...).
17
17
  With no dials or strip to lean on, three keys are reserved *by grid
18
18
  position* (computed from `key_layout` rows x cols, never hardcoded)
19
- for the roles the dials/strip played:
20
- * VIEW = top-left (index 0): shows the current view name +
21
- server label (what the strip showed); a tap opens a paged view
22
- picker on the session-slot keys (dial-0's picker role) -- VIEW
23
- becomes BACK, PREV/NEXT page the list, tapping a view selects it.
24
- * PREV = bottom-left (index (rows-1)*cols): previous page.
25
- * NEXT = bottom-right (index rows*cols-1): next page (dial-1's role).
26
- Every remaining key is a session tile in reading order, so
27
- sessions_per_page = key_count - 3 (12 on a 15-key deck).
19
+ for the roles the dials/strip played. Which three positions depends on
20
+ grid shape alone (see `_reserved_control_keys`), never on model name:
21
+
22
+ * Exactly 3 columns, 2+ rows (e.g. the Stream Deck Mini, 3 cols x 2
23
+ rows): 3 columns is exactly enough to dedicate the *entire bottom
24
+ row* to controls, reading left to right as PREV, VIEW, NEXT --
25
+ every row above it is entirely session tiles. This was chosen over
26
+ reusing the corner layout below because on a 3-wide grid the
27
+ corner scheme leaves session tiles awkwardly split around a lone
28
+ reserved key in the bottom row (e.g. keys 1, 2, 4 on a 3x2 grid --
29
+ a gap where key 3 (PREV) sits), whereas a full control row leaves
30
+ every session tile contiguous on the row(s) above.
31
+ * Everything else (any other column count): reserve the three
32
+ corners -- VIEW top-left (index 0), PREV bottom-left
33
+ (index (rows-1)*cols), NEXT bottom-right (index rows*cols-1) --
34
+ the original REDUCED geometry, ported from dial-0/dial-1's roles
35
+ on the Stream Deck+. Unchanged for the 15-key Original (3x5) and
36
+ XL (4x8).
37
+
38
+ In both cases VIEW shows the current view name + server label (what
39
+ the strip showed); a tap opens a paged view picker on the session-slot
40
+ keys (dial-0's picker role) -- VIEW becomes BACK, PREV/NEXT page the
41
+ list, tapping a view selects it. Every remaining key is a session tile
42
+ in reading order, so sessions_per_page = key_count - 3 (12 on a 15-key
43
+ deck, 3 on a 6-key Mini).
28
44
 
29
45
  Edge cases (degrade gracefully, never crash):
30
46
 
@@ -33,10 +49,10 @@ Edge cases (degrade gracefully, never crash):
33
49
  - Touchscreen but fewer than two dials: REDUCED for the keys, but the
34
50
  strip is still used for the status headline (`use_strip` stays True) --
35
51
  free extra signal, no behavior depends on it.
36
- - Fewer than 4 keys, or a grid so degenerate the three reserved corners
37
- collide (e.g. a single row, where top-left == bottom-left): every key
38
- becomes a session tile with no view/page controls -- a plain session
39
- switcher beats three controls with no sessions.
52
+ - Fewer than 4 keys, or a grid so degenerate the three reserved control
53
+ keys collide (e.g. a single row, where top-left == bottom-left): every
54
+ key becomes a session tile with no view/page controls -- a plain
55
+ session switcher beats three controls with no sessions.
40
56
 
41
57
  Everything here is pure (no device I/O, no threads), so both layout modes
42
58
  are unit-testable with fake capability dicts.
@@ -102,6 +118,27 @@ class LayoutPlan:
102
118
  use_strip: bool # paint the touch strip (status headline)?
103
119
 
104
120
 
121
+ def _reserved_control_keys(rows: int, cols: int) -> tuple[int, int, int]:
122
+ """Compute `(view_key, prev_key, next_key)` physical key indices.
123
+
124
+ Chosen by grid shape alone (`key_rows` x `key_cols`) -- never by model
125
+ name. Two geometries:
126
+
127
+ - Exactly 3 columns, 2+ rows: the bottom row exactly fits all three
128
+ controls, reading left to right as PREV, VIEW, NEXT. Every row above
129
+ it is entirely session tiles. A 3-wide grid is the one shape where a
130
+ full control row is both possible (no leftover) and better than the
131
+ corner scheme below, which would otherwise strand session tiles
132
+ around a lone reserved key mid-row.
133
+ - Everything else: reserve the three corners -- VIEW top-left, PREV
134
+ bottom-left, NEXT bottom-right -- the original REDUCED geometry.
135
+ """
136
+ if cols == 3 and rows >= 2:
137
+ bottom_row_start = (rows - 1) * cols
138
+ return bottom_row_start + 1, bottom_row_start, bottom_row_start + 2
139
+ return 0, (rows - 1) * cols, rows * cols - 1
140
+
141
+
105
142
  def plan_layout(caps: Mapping[str, Any]) -> LayoutPlan:
106
143
  """Choose the layout mode and key assignments for a deck's capabilities.
107
144
 
@@ -131,9 +168,7 @@ def plan_layout(caps: Mapping[str, Any]) -> LayoutPlan:
131
168
  use_strip=True,
132
169
  )
133
170
 
134
- view_key = 0
135
- prev_key = (rows - 1) * cols
136
- next_key = rows * cols - 1
171
+ view_key, prev_key, next_key = _reserved_control_keys(rows, cols)
137
172
  reserved = (view_key, prev_key, next_key)
138
173
  degenerate = (
139
174
  key_count < _REDUCED_MIN_KEYS
@@ -0,0 +1,416 @@
1
+ """Terminal-output rendering for `doctor` / `status` / `service` -- pure text.
2
+
3
+ Three bands, always this order, separated by exactly ONE blank line, with
4
+ leading/trailing blank lines as the only frame::
5
+
6
+ VERDICT one line, column 0
7
+ STATE the checks or readouts, indented
8
+ ACTION "Do this:" + exactly one decision (omitted when nothing to do)
9
+
10
+ No ANSI color, no box drawing, no tree connectors -- the acid test is the
11
+ paste: the user's primary act is selecting this output and pasting it as
12
+ plain text, so every emphasis device must survive that. Output is
13
+ byte-identical whether printed to a TTY or piped to a file; the only thing
14
+ that varies with environment is the `+` (fine) glyph, which becomes a
15
+ Unicode checkmark when the environment is UTF-8-capable (see
16
+ `utf8_capable`). This module does no I/O itself -- callers decide what to
17
+ check and how to phrase it; this module only lays it out.
18
+
19
+ Column ladder (exact, see the layout-architect review this implements)::
20
+
21
+ check line 2sp + glyph + 2sp + subject padded to 14 + value (col 19)
22
+ continuation 19sp + text (hangs under the value column)
23
+ readout line 5sp + name padded to 14 + value -- gutter left empty
24
+ group label column 0, lowercase, blank line above (--all only)
25
+ command to run indented +4 from its governing text
26
+ prose in ACTION column 2, max 2 lines
27
+
28
+ Only VERDICT, "Do this:", and `--all` group labels ever touch column 0.
29
+ Below 60 columns, subject padding drops to a single space (no other
30
+ reflow); above 80, nothing changes -- fields are never stretched to fill
31
+ a wide terminal, and wrapping always happens at column 78.
32
+
33
+ The gutter law: the gutter is empty if and only if there is nothing to do.
34
+ A fully healthy report has no glyphs at all -- see `Readout` -- and `+`
35
+ appears only for contrast alongside a failure in the same report, or under
36
+ `--all`.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import os
42
+ import re
43
+ import textwrap
44
+ from dataclasses import dataclass, field
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Glyphs -- exactly three, ASCII by design (see module docstring on UTF-8).
48
+ # ---------------------------------------------------------------------------
49
+
50
+ ACT = "!" # act now -- the root cause; a command exists
51
+ FINE = "+" # nothing to do
52
+ BLOCKED = "-" # cannot be done or evaluated until an upstream item resolves
53
+
54
+ # Rollup severity for grouped checks: "!" > "-" > "+" (higher = worse).
55
+ _SEVERITY = {ACT: 2, BLOCKED: 1, FINE: 0}
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Column geometry
59
+ # ---------------------------------------------------------------------------
60
+
61
+ _GUTTER_WIDTH = 5 # " " + glyph/space + " "
62
+ _SUBJECT_WIDTH = 14
63
+ _NARROW_SUBJECT_WIDTH = 1 # below _NARROW_THRESHOLD, drop padding to 1 space
64
+ _NARROW_THRESHOLD = 60
65
+ _DEFAULT_WIDTH = 78
66
+ _ACTION_PROSE_COLUMN = 2
67
+ _ACTION_COMMAND_INDENT = 4 # "+4 from its governing text"
68
+
69
+
70
+ def _subject_field(subject: str, width: int) -> str:
71
+ """The subject column's text, including its trailing separator.
72
+
73
+ At normal widths this is `subject` left-justified to 14 columns. Below
74
+ `_NARROW_THRESHOLD` columns, padding drops to a single separating space
75
+ instead (the spec's "drop subject padding to a single space") -- the
76
+ column ladder is no longer aligned at that width, which is expected.
77
+ """
78
+ if width < _NARROW_THRESHOLD:
79
+ return f"{subject} "
80
+ return subject.ljust(_SUBJECT_WIDTH)
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # UTF-8 glyph substitution -- "+"/"✓" only. "!" and "-" never change: a
85
+ # diagnostic tool must not emit output that can be corrupted by the very
86
+ # environment it is diagnosing, so the ASCII forms are always valid; the
87
+ # checkmark is a cosmetic upgrade applied only when it is safe.
88
+ # ---------------------------------------------------------------------------
89
+
90
+
91
+ def utf8_capable(env: dict[str, str] | None = None) -> bool:
92
+ """Best-effort: can this environment render a UTF-8 checkmark safely?
93
+
94
+ Checks LC_ALL, LC_CTYPE, LANG in that order (the standard POSIX locale
95
+ precedence) for a "UTF-8"/"utf8" marker. Defaults to `os.environ`;
96
+ callers (and tests) may pass an explicit mapping instead.
97
+ """
98
+ source = os.environ if env is None else env
99
+ for key in ("LC_ALL", "LC_CTYPE", "LANG"):
100
+ value = source.get(key)
101
+ if not value:
102
+ continue
103
+ normalized = value.lower()
104
+ if "utf-8" in normalized or "utf8" in normalized:
105
+ return True
106
+ return False
107
+
108
+
109
+ def _glyph_char(glyph: str, *, utf8: bool) -> str:
110
+ if glyph == FINE and utf8:
111
+ return "\u2713"
112
+ return glyph
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Data model
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ @dataclass
121
+ class Check:
122
+ """One line of STATE: a glyph, a subject, and a value."""
123
+
124
+ subject: str
125
+ glyph: str
126
+ value: str
127
+
128
+
129
+ @dataclass
130
+ class Group:
131
+ """A named cluster of `Check`s that collapses to one line by default.
132
+
133
+ Default mode renders `rollup()` (glyph = worst member, value = that
134
+ member's value). `--all` renders every member instead, under a
135
+ column-0 group label -- a strict superset, never a different answer.
136
+ """
137
+
138
+ subject: str
139
+ members: list[Check]
140
+
141
+ def rollup(self) -> Check:
142
+ worst = max(self.members, key=lambda c: _SEVERITY[c.glyph])
143
+ return Check(subject=self.subject, glyph=worst.glyph, value=worst.value)
144
+
145
+
146
+ @dataclass
147
+ class Readout:
148
+ """One line of STATE with no glyph at all -- the all-fine gutter-empty form."""
149
+
150
+ name: str
151
+ value: str
152
+
153
+
154
+ Item = Check | Group
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Wrapping -- hard-wrap at column 78 (or the given width), never soft-wrap.
158
+ # Lines that already fit are left byte-for-byte alone (so hand-formatted
159
+ # multi-line guidance blocks with their own bullet indentation survive);
160
+ # only a line that genuinely overflows gets reflowed, and only that line.
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ def _wrap_logical_line(line: str, avail: int) -> list[str]:
165
+ avail = max(10, avail)
166
+ if len(line) <= avail:
167
+ return [line]
168
+ wrapped = textwrap.wrap(
169
+ line, width=avail, break_long_words=False, break_on_hyphens=False
170
+ )
171
+ return wrapped or [""]
172
+
173
+
174
+ def _render_value_block(
175
+ prefix: str, value: str, *, value_column: int, width: int
176
+ ) -> str:
177
+ """Render `prefix` + `value`, wrapping/hanging continuations at `value_column`."""
178
+ avail = max(10, width - value_column)
179
+ logical_lines = value.splitlines() or [""]
180
+ out: list[str] = []
181
+ first = True
182
+ for logical in logical_lines:
183
+ for fragment in _wrap_logical_line(logical, avail):
184
+ if first:
185
+ out.append(prefix + fragment)
186
+ first = False
187
+ else:
188
+ out.append(" " * value_column + fragment)
189
+ return "\n".join(out)
190
+
191
+
192
+ def format_check_line(
193
+ glyph: str, subject: str, value: str, *, utf8: bool, width: int = _DEFAULT_WIDTH
194
+ ) -> str:
195
+ """Render one STATE check line: ` <glyph> <subject><value>` (+ wraps)."""
196
+ display_glyph = _glyph_char(glyph, utf8=utf8)
197
+ prefix = f" {display_glyph} {_subject_field(subject, width)}"
198
+ return _render_value_block(prefix, value, value_column=len(prefix), width=width)
199
+
200
+
201
+ def format_readout_line(name: str, value: str, *, width: int = _DEFAULT_WIDTH) -> str:
202
+ """Render one STATE readout line: gutter left empty (the all-fine form)."""
203
+ prefix = " " * _GUTTER_WIDTH + _subject_field(name, width)
204
+ return _render_value_block(prefix, value, value_column=len(prefix), width=width)
205
+
206
+
207
+ def group_label(subject: str) -> str:
208
+ """The `--all`-only column-0 group heading (lowercase, blank line above it)."""
209
+ return subject.lower()
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Rendering a list of Check/Group items into STATE lines
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def collapsed_checks(items: list[Item]) -> list[Check]:
218
+ """Flatten groups to their rollup -- the representation the VERDICT/ACTION
219
+
220
+ bands are always computed from, so both bands stay identical between
221
+ default and `--all` mode (the superset property: only the middle
222
+ expands).
223
+ """
224
+ return [item.rollup() if isinstance(item, Group) else item for item in items]
225
+
226
+
227
+ def render_items(
228
+ items: list[Item], *, show_all: bool, utf8: bool, width: int = _DEFAULT_WIDTH
229
+ ) -> list[str]:
230
+ """Render STATE lines for a list of Check/Group items.
231
+
232
+ Default mode: each Group renders as its single rolled-up line. `--all`:
233
+ each Group expands into a column-0 lowercase label followed by every
234
+ member as its own check line; ungrouped Checks render identically in
235
+ both modes (nothing to expand).
236
+ """
237
+ lines: list[str] = []
238
+ need_blank_before_next_group = False
239
+ for item in items:
240
+ if isinstance(item, Group):
241
+ if show_all:
242
+ if need_blank_before_next_group:
243
+ lines.append("")
244
+ lines.append(group_label(item.subject))
245
+ for member in item.members:
246
+ lines.append(
247
+ format_check_line(
248
+ member.glyph,
249
+ member.subject,
250
+ member.value,
251
+ utf8=utf8,
252
+ width=width,
253
+ )
254
+ )
255
+ need_blank_before_next_group = True
256
+ continue
257
+ rolled = item.rollup()
258
+ lines.append(
259
+ format_check_line(
260
+ rolled.glyph, rolled.subject, rolled.value, utf8=utf8, width=width
261
+ )
262
+ )
263
+ else:
264
+ lines.append(
265
+ format_check_line(
266
+ item.glyph, item.subject, item.value, utf8=utf8, width=width
267
+ )
268
+ )
269
+ need_blank_before_next_group = False
270
+ return lines
271
+
272
+
273
+ def render_readouts(
274
+ readouts: list[Readout], *, width: int = _DEFAULT_WIDTH
275
+ ) -> list[str]:
276
+ return [format_readout_line(r.name, r.value, width=width) for r in readouts]
277
+
278
+
279
+ # ---------------------------------------------------------------------------
280
+ # VERDICT -- counts ACTIONS, not problems.
281
+ # ---------------------------------------------------------------------------
282
+
283
+
284
+ def count_actions(glyphs: list[str]) -> int:
285
+ return sum(1 for g in glyphs if g == ACT)
286
+
287
+
288
+ def verdict_readiness(action_count: int, *, note: str | None = None) -> str:
289
+ """ "Ready."/"Not ready -- N thing(s) to do[, note]." -- the doctor-style verdict."""
290
+ if action_count <= 0:
291
+ return "Ready."
292
+ word = "thing" if action_count == 1 else "things"
293
+ base = f"Not ready -- {action_count} {word} to do"
294
+ if note:
295
+ base = f"{base}, {note}"
296
+ return base + "."
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # ACTION band
301
+ # ---------------------------------------------------------------------------
302
+
303
+
304
+ def prose_lines(text: str, *, width: int = _DEFAULT_WIDTH) -> list[str]:
305
+ """Prose in ACTION: column 2, wrapped at `width`."""
306
+ avail = max(10, width - _ACTION_PROSE_COLUMN)
307
+ out: list[str] = []
308
+ for logical in text.splitlines() or [text]:
309
+ out.extend(_wrap_logical_line(logical, avail))
310
+ return [" " * _ACTION_PROSE_COLUMN + line for line in out]
311
+
312
+
313
+ @dataclass
314
+ class Decision:
315
+ """A single ACTION decision: a contiguous command sequence, plus prose."""
316
+
317
+ commands: list[str]
318
+ prose: str | None = None # max 2 lines
319
+
320
+
321
+ @dataclass
322
+ class Step:
323
+ """One numbered step of a multi-step, cross-machine ACTION."""
324
+
325
+ number: int
326
+ intro: str # step body text, e.g. "On Windows, in an Administrator PowerShell:"
327
+ commands: list[str] = field(default_factory=list)
328
+ note: str | None = None # continuation prose specific to this step
329
+
330
+
331
+ @dataclass
332
+ class Action:
333
+ decision: Decision | None = None
334
+ steps: list[Step] = field(default_factory=list)
335
+ overflow_note: str | None = None # e.g. "2 more after this -- rerun doctor."
336
+
337
+
338
+ def _render_decision(decision: Decision, *, width: int) -> list[str]:
339
+ lines: list[str] = []
340
+ if decision.commands:
341
+ lines.append("")
342
+ for cmd in decision.commands:
343
+ lines.append(" " * _ACTION_COMMAND_INDENT + cmd)
344
+ if decision.prose:
345
+ lines.append("")
346
+ lines.extend(prose_lines(decision.prose, width=width))
347
+ return lines
348
+
349
+
350
+ def _render_step(step: Step, *, width: int) -> list[str]:
351
+ marker = f"{step.number}. "
352
+ body_column = _ACTION_PROSE_COLUMN + len(marker)
353
+ command_column = body_column + _ACTION_COMMAND_INDENT
354
+ lines = [" " * _ACTION_PROSE_COLUMN + marker + step.intro]
355
+ if step.commands:
356
+ lines.append("")
357
+ for cmd in step.commands:
358
+ lines.append(" " * command_column + cmd)
359
+ if step.note:
360
+ lines.append("")
361
+ for logical in step.note.splitlines() or [step.note]:
362
+ lines.append(" " * body_column + logical)
363
+ return lines
364
+
365
+
366
+ def render_action(action: Action, *, width: int = _DEFAULT_WIDTH) -> list[str]:
367
+ """Render the full ACTION band, including its "Do this:" header line."""
368
+ lines: list[str] = ["Do this:"]
369
+ if action.decision is not None:
370
+ lines.extend(_render_decision(action.decision, width=width))
371
+ else:
372
+ for index, step in enumerate(action.steps):
373
+ if index > 0:
374
+ lines.append("")
375
+ lines.extend(_render_step(step, width=width))
376
+ if action.overflow_note:
377
+ lines.append("")
378
+ lines.extend(prose_lines(action.overflow_note, width=width))
379
+ return lines
380
+
381
+
382
+ # ---------------------------------------------------------------------------
383
+ # Top-level assembly
384
+ # ---------------------------------------------------------------------------
385
+
386
+
387
+ def render(
388
+ verdict: str, state_lines: list[str], action_lines: list[str] | None = None
389
+ ) -> str:
390
+ """Assemble VERDICT / STATE / ACTION with exactly one blank-line separator
391
+
392
+ between bands and a leading/trailing blank line as the only frame.
393
+ Returns the full text -- callers should write it verbatim (e.g.
394
+ `sys.stdout.write(...)`), not `print()` it (which would add a second
395
+ trailing newline).
396
+ """
397
+ lines: list[str] = ["", verdict, "", *state_lines]
398
+ if action_lines:
399
+ lines += ["", *action_lines]
400
+ return "\n".join(lines) + "\n\n"
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Helper for callers extracting an embedded "run: <command>" suggestion out
405
+ # of an existing check message -- lets ACTION reuse guidance text that
406
+ # already exists in check functions instead of duplicating it.
407
+ # ---------------------------------------------------------------------------
408
+
409
+ _RUN_COMMAND_RE = re.compile(r"run:\s*(muxplex-deck[^\n.]*)")
410
+
411
+
412
+ def extract_run_command(message: str) -> str | None:
413
+ match = _RUN_COMMAND_RE.search(message)
414
+ if not match:
415
+ return None
416
+ return match.group(1).strip()
@@ -139,19 +139,29 @@ _LAUNCHD_PLIST_TEMPLATE = """\
139
139
  </plist>
140
140
  """
141
141
 
142
- # Same ✓/! 2-space-indent style as `cli.doctor()`'s `print_check` -- kept as
143
- # a small local duplicate (rather than importing from `cli.py`) to avoid a
144
- # circular import: `cli.py` already imports from this module at call time.
145
- _MARK_OK = "\033[32m\u2713\033[0m"
146
- _MARK_WARN = "\033[33m!\033[0m"
142
+
143
+ # Same glyph vocabulary as `report.py` (see that module's docstring): "+"
144
+ # for fine, becoming a UTF-8 checkmark only when the environment can render
145
+ # one. This install/uninstall narration is step-by-step progress output,
146
+ # not a VERDICT/STATE/ACTION report -- it intentionally keeps its own
147
+ # simple 2-space-indent style rather than adopting the full column ladder,
148
+ # but shares the same glyph-substitution rule so the visual language is
149
+ # consistent across `doctor`/`status`/`service`. No ANSI color: plain text
150
+ # only, so this stays byte-identical whether printed to a TTY or piped.
151
+ def _mark_ok() -> str:
152
+ from . import report
153
+
154
+ return report._glyph_char(report.FINE, utf8=report.utf8_capable())
147
155
 
148
156
 
149
157
  def _step_ok(message: str) -> None:
150
- print(f" {_MARK_OK} {message}")
158
+ print(f" {_mark_ok()} {message}")
151
159
 
152
160
 
153
161
  def _step_warn(message: str) -> None:
154
- print(f" {_MARK_WARN} {message}")
162
+ from . import report
163
+
164
+ print(f" {report.ACT} {message}")
155
165
 
156
166
 
157
167
  # ---------------------------------------------------------------------------
@@ -256,8 +266,10 @@ def _print_guidance_block(message: str) -> None:
256
266
  uses (kept as a small local duplicate here to avoid a circular import
257
267
  with `cli.py`).
258
268
  """
269
+ from . import report
270
+
259
271
  for i, line in enumerate(message.splitlines()):
260
- prefix = f" {_MARK_WARN} " if i == 0 else " "
272
+ prefix = f" {report.ACT} " if i == 0 else " "
261
273
  print(f"{prefix}{line}")
262
274
 
263
275
 
@@ -122,7 +122,7 @@ def build_status(
122
122
  `hidhelp.explain_open_failure`), published so `muxplex-deck status`
123
123
  can show it instead of a stale/misleading status. Older readers that
124
124
  don't know this field simply don't see it (`.get()` is used to read
125
- it back -- see `cli._format_device_line`).
125
+ it back -- see `cli._status_device_item`).
126
126
  """
127
127
  device: dict[str, Any] = {"connected": device_connected}
128
128
  if device_connected and device_caps is not None:
File without changes