groundskeeping 0.2.0__tar.gz → 0.2.2__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 (71) hide show
  1. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/PKG-INFO +5 -2
  2. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/README.md +4 -1
  3. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/pages.md +8 -0
  4. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/app.py +18 -15
  5. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/__init__.py +1 -0
  6. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/views.py +16 -1
  7. groundskeeping-0.2.2/src/groundskeeping/py.typed +1 -0
  8. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/wizard.py +39 -7
  9. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/workbench.py +29 -7
  10. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_app_smoke.py +150 -1
  11. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_wizard_contracts.py +147 -1
  12. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  13. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  14. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  15. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/not_working.yml +0 -0
  16. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  17. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/release-drafter.yml +0 -0
  18. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/ci.yml +0 -0
  19. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/docs.yml +0 -0
  20. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/merge.yml +0 -0
  21. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/publish.yml +0 -0
  22. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.gitignore +0 -0
  23. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/CONTRIBUTING.md +0 -0
  24. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/actions.md +0 -0
  25. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/actions.md +0 -0
  26. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/app.md +0 -0
  27. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/configurator.md +0 -0
  28. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/jobs.md +0 -0
  29. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/pages.md +0 -0
  30. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/telemetry.md +0 -0
  31. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/views.md +0 -0
  32. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/widgets.md +0 -0
  33. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/wizards.md +0 -0
  34. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/configuration.md +0 -0
  35. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/contributing.md +0 -0
  36. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/index.md +0 -0
  37. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/ownership.md +0 -0
  38. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/quickstart.md +0 -0
  39. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/static/images/demo-layout-example.png +0 -0
  40. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/telemetry.md +0 -0
  41. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/wizards.md +0 -0
  42. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/examples/demo_app.py +0 -0
  43. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/mkdocs.yml +0 -0
  44. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/pyproject.toml +0 -0
  45. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/__init__.py +0 -0
  46. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/__init__.py +0 -0
  47. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/adapter.py +0 -0
  48. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/models.py +0 -0
  49. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/actions.py +0 -0
  50. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/jobs.py +0 -0
  51. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/pages.py +0 -0
  52. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/telemetry.py +0 -0
  53. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/wizards.py +0 -0
  54. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/demo.py +0 -0
  55. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/navigation.py +0 -0
  56. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/__init__.py +0 -0
  57. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/__init__.py +0 -0
  58. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/fake.py +0 -0
  59. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/runtime.py +0 -0
  60. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/theme.py +0 -0
  61. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/themes/groundskeeping.tcss +0 -0
  62. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/__init__.py +0 -0
  63. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/configurator.py +0 -0
  64. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/primitives.py +0 -0
  65. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/telemetry.py +0 -0
  66. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_actions_and_jobs.py +0 -0
  67. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_configurator_adapter.py +0 -0
  68. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_dependency_boundaries.py +0 -0
  69. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_navigation_contracts.py +0 -0
  70. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_telemetry_core.py +0 -0
  71. {groundskeeping-0.2.0 → groundskeeping-0.2.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: groundskeeping
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Reusable Textual operator shell, workbench, configuration browser, and telemetry primitives.
5
5
  Author: UNSW CAVA platform tooling
6
6
  Requires-Python: >=3.12
@@ -75,6 +75,9 @@ OperatorApp(spec).run()
75
75
 
76
76
  Groundskeeping owns the frame around those pages. `DatabaseSetupPage`,
77
77
  `EmbeddingsSetupPage`, and the services behind them stay in the application.
78
+ Use `workbench_labels` on `OperatorAppSpec` when the shared pane chrome needs different
79
+ language, such as naming the upper-right pane **Checks** instead of **Rows**. Page navigation
80
+ and view contracts still supply their own content titles.
78
81
 
79
82
  The default page surface is the workbench:
80
83
 
@@ -91,7 +94,7 @@ Read the screenshot from left to right:
91
94
  | Top tabs | The registered pages in the app | `PageRoute`, `PageRegistration` |
92
95
  | Left pane | Things the operator can move between inside the current page | `SectionNavigation` + `SectionItem`, or `CatalogueNavigation` + `CatalogueItem` |
93
96
  | Upper-right pane | The main content for the selected section or catalogue item | `TableView`, `TreeView`, `EmptyView`, `LoadingView` |
94
- | Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView` |
97
+ | Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView`, `TableView` |
95
98
  | Buttons above the view | Commands for the current view only | `ViewAction`, routed to `action_selected` |
96
99
 
97
100
  Pages choose `SectionNavigation` for peer areas or `CatalogueNavigation` for hierarchical
@@ -58,6 +58,9 @@ OperatorApp(spec).run()
58
58
 
59
59
  Groundskeeping owns the frame around those pages. `DatabaseSetupPage`,
60
60
  `EmbeddingsSetupPage`, and the services behind them stay in the application.
61
+ Use `workbench_labels` on `OperatorAppSpec` when the shared pane chrome needs different
62
+ language, such as naming the upper-right pane **Checks** instead of **Rows**. Page navigation
63
+ and view contracts still supply their own content titles.
61
64
 
62
65
  The default page surface is the workbench:
63
66
 
@@ -74,7 +77,7 @@ Read the screenshot from left to right:
74
77
  | Top tabs | The registered pages in the app | `PageRoute`, `PageRegistration` |
75
78
  | Left pane | Things the operator can move between inside the current page | `SectionNavigation` + `SectionItem`, or `CatalogueNavigation` + `CatalogueItem` |
76
79
  | Upper-right pane | The main content for the selected section or catalogue item | `TableView`, `TreeView`, `EmptyView`, `LoadingView` |
77
- | Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView` |
80
+ | Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView`, `TableView` |
78
81
  | Buttons above the view | Commands for the current view only | `ViewAction`, routed to `action_selected` |
79
82
 
80
83
  Pages choose `SectionNavigation` for peer areas or `CatalogueNavigation` for hierarchical
@@ -23,6 +23,11 @@ Translate domain objects before they reach the workbench. The workbench understa
23
23
  `SectionItem`, `CatalogueItem`, `TableView`, `TreeView`, and friends; it does not need to know
24
24
  what a Groundworkers resource or `cava-nlp-shard` evaluation object is.
25
25
 
26
+ Use `OperatorAppSpec.workbench_labels` to rename shared pane chrome such as the result and
27
+ detail panel labels. Page-owned titles still live on navigation and view contracts: for
28
+ example, `SectionNavigation.title` names the left pane for a specific page, and
29
+ `TableView.title` names the current result content.
30
+
26
31
  ## Routing
27
32
 
28
33
  `PageRoute` is the navigation identity for one page: a `key`, an operator-facing `label`, and
@@ -49,6 +54,9 @@ startup rather than when an operator clicks a tab.
49
54
  Row events return to the active page. The workbench renders generic models; the page decides
50
55
  what a highlighted row means.
51
56
 
57
+ Detail panes can render `TextView`, `KeyValueView`, or `TableView`. Use a detail `TableView`
58
+ when the selected item has its own repeated data, such as available LLM models for a provider.
59
+
52
60
  If `landing_view` raises, the shell catches the exception and renders an `EmptyView`
53
61
  explaining that the page could not be rendered. A page that cannot build its landing content
54
62
  degrades to a message instead of taking down the app.
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Mapping
6
6
  from dataclasses import dataclass, field
7
- from typing import ClassVar
7
+ from typing import ClassVar, cast
8
8
 
9
9
  from rich.text import Text
10
10
  from textual.app import App, ComposeResult
@@ -52,6 +52,7 @@ from groundskeeping.contracts.views import (
52
52
  PageNavigation,
53
53
  SectionItem,
54
54
  SurfaceView,
55
+ WorkbenchLabels,
55
56
  )
56
57
  from groundskeeping.contracts.wizards import (
57
58
  WizardController,
@@ -76,6 +77,7 @@ class OperatorAppSpec:
76
77
  operation_policy: OperationPolicy = field(default_factory=AllowAllOperationPolicy)
77
78
  job_policy: JobPolicy = field(default_factory=SingleForegroundJobPolicy)
78
79
  result_presenter: ResultPresenter = field(default_factory=DefaultResultPresenter)
80
+ workbench_labels: WorkbenchLabels = field(default_factory=WorkbenchLabels)
79
81
  default_page: str | None = None
80
82
  metadata: Mapping[str, object] | None = None
81
83
 
@@ -157,7 +159,7 @@ class OperatorApp(App[None]):
157
159
  self.sub_title = spec.subtitle or ""
158
160
  self.jobs = JobManager(spec.job_policy)
159
161
  self._active_page = spec.default_page or self.registry[0].key
160
- self._workbench = Workbench()
162
+ self._workbench = Workbench(spec.workbench_labels)
161
163
  self._surface = _WorkbenchSurface(self._workbench)
162
164
  # Textual's App already owns an internal `_context()` method used during
163
165
  # startup. Keep the page-facing context under a distinct name so the shell does
@@ -180,13 +182,11 @@ class OperatorApp(App[None]):
180
182
  with Vertical(id="workspace"):
181
183
  yield Label("", id="workspace-title")
182
184
  for route in self.registry:
183
- page = self._pages[route.key]
184
- if not isinstance(page, Widget):
185
- raise TypeError(f"Page {route.key!r} must be a Textual Widget.")
186
- page.add_class("operator-page")
187
- page.styles.height = 0
188
- page.styles.max_height = 0
189
- yield page
185
+ page_widget = self._page_widget(route.key)
186
+ page_widget.add_class("operator-page")
187
+ page_widget.styles.height = 0
188
+ page_widget.styles.max_height = 0
189
+ yield page_widget
190
190
  yield self._workbench
191
191
  yield Footer()
192
192
 
@@ -254,16 +254,13 @@ class OperatorApp(App[None]):
254
254
  next_page = self._pages[page_key]
255
255
  if previous is not None and previous is not next_page:
256
256
  previous.deactivate(self._page_context)
257
- if isinstance(previous, Widget):
258
- previous.remove_class("-active")
257
+ self._page_widget(self._active_page).remove_class("-active")
259
258
  self._active_page = page_key
260
- page = next_page
261
- if isinstance(page, Widget):
262
- page.add_class("-active")
259
+ self._page_widget(page_key).add_class("-active")
263
260
  tabs = self.query_one("#page-tabs", Tabs)
264
261
  if tabs.active != page_key:
265
262
  tabs.active = page_key
266
- page.activate(self._page_context)
263
+ next_page.activate(self._page_context)
267
264
  self._render_page(route)
268
265
 
269
266
  def _render_page(self, route: PageRoute) -> None:
@@ -286,6 +283,12 @@ class OperatorApp(App[None]):
286
283
  def _active_widget(self) -> OperatorPage:
287
284
  return self._pages[self._active_page]
288
285
 
286
+ def _page_widget(self, page_key: str) -> Widget:
287
+ page = self._pages[page_key]
288
+ if not isinstance(page, Widget):
289
+ raise TypeError(f"Page {page_key!r} must be a Textual Widget.")
290
+ return cast(Widget, page)
291
+
289
292
  def _select_navigation_item(self, item: NavigationItem) -> None:
290
293
  self._active_widget().navigation_selected(item, self._page_context)
291
294
 
@@ -69,6 +69,7 @@ from groundskeeping.contracts.views import (
69
69
  TreeView,
70
70
  ViewAction,
71
71
  ViewActionVariant,
72
+ WorkbenchLabels,
72
73
  )
73
74
  from groundskeeping.contracts.wizards import (
74
75
  Choice,
@@ -12,6 +12,21 @@ type ViewActionVariant = Literal["default", "primary", "success", "warning", "er
12
12
  MAX_VIEW_ACTIONS = 6
13
13
 
14
14
 
15
+ @dataclass(frozen=True)
16
+ class WorkbenchLabels:
17
+ """Operator-facing labels for reusable workbench chrome.
18
+
19
+ Page-owned content still supplies its own titles through navigation and view contracts.
20
+ These labels only cover structural defaults in the shared shell.
21
+ """
22
+
23
+ navigation_panel: str = "Sections"
24
+ result_panel: str = "Rows"
25
+ detail_panel: str = "Detail"
26
+ initial_result_summary: str = "Select a section to inspect it."
27
+ key_value_columns: tuple[str, str] = ("Field", "Value")
28
+
29
+
15
30
  class SemanticStatus(StrEnum):
16
31
  """Small health/status vocabulary understood by shared widgets."""
17
32
 
@@ -160,5 +175,5 @@ class KeyValueView:
160
175
  title: str = "Detail"
161
176
 
162
177
 
163
- type DetailView = TextView | KeyValueView
178
+ type DetailView = TextView | KeyValueView | TableView
164
179
  type SurfaceView = TableView | TreeView | EmptyView | LoadingView
@@ -14,10 +14,12 @@ from textual.widgets import (
14
14
  DataTable,
15
15
  Input,
16
16
  Label,
17
+ OptionList,
17
18
  Select,
18
19
  Static,
19
20
  TextArea,
20
21
  )
22
+ from textual.widgets.option_list import Option
21
23
 
22
24
  from groundskeeping.contracts.actions import FieldKind, FieldSpec
23
25
  from groundskeeping.contracts.wizards import (
@@ -42,6 +44,7 @@ class WizardScreen(ModalScreen[WizardResult]):
42
44
  self._snapshot: WizardSnapshot | None = None
43
45
  self._field_by_widget_id: dict[str, FieldSpec] = {}
44
46
  self._choice_widget_id = "wizard-choice"
47
+ self._blank_choice_id = "__groundskeeping_blank_choice__"
45
48
 
46
49
  def compose(self) -> ComposeResult:
47
50
  with Vertical(id="wizard-frame"):
@@ -149,21 +152,45 @@ class WizardScreen(ModalScreen[WizardResult]):
149
152
  for warning in step.review.warnings:
150
153
  await body.mount(Static(f"Warning: {warning}", classes="wizard-warning"))
151
154
 
152
- def _field_widget(self, widget_id: str, field: FieldSpec, value: object) -> Input | TextArea | Checkbox | Select:
155
+ def _field_widget(
156
+ self, widget_id: str, field: FieldSpec, value: object
157
+ ) -> Input | TextArea | Checkbox | OptionList:
153
158
  disabled = field.disabled or field.read_only
154
159
  if field.kind is FieldKind.BOOLEAN:
155
160
  return Checkbox(value=bool(value if value is not None else field.default), id=widget_id, disabled=disabled)
156
161
  if field.kind is FieldKind.CHOICE:
157
- options = tuple((choice.label, choice.value) for choice in field.choices)
158
162
  current = value if value is not None else field.default
159
- return Select(
160
- options,
161
- prompt=field.placeholder or "Choose",
162
- value=current if current is not None else Select.BLANK,
163
+ options = []
164
+ if not field.required:
165
+ options.append(
166
+ Option(field.placeholder or "No selection", id=self._blank_choice_id)
167
+ )
168
+ options.extend(
169
+ Option(
170
+ choice.label
171
+ if choice.description is None
172
+ else f"{choice.label} — {choice.description}",
173
+ id=choice.value,
174
+ )
175
+ for choice in field.choices
176
+ )
177
+ if not options:
178
+ options.append(Option(field.placeholder or "No choices", disabled=True))
179
+ widget = OptionList(
180
+ *options,
163
181
  id=widget_id,
164
- allow_blank=not field.required,
165
182
  disabled=disabled,
183
+ classes="wizard-choice-list",
166
184
  )
185
+ widget.styles.height = min(max(len(options), 2), 8)
186
+ choice_ids = tuple(option.id for option in options)
187
+ if current is None and not field.required:
188
+ widget.highlighted = 0
189
+ elif current in choice_ids:
190
+ widget.highlighted = choice_ids.index(current)
191
+ elif field.required and field.choices:
192
+ widget.highlighted = 0
193
+ return widget
167
194
  if field.kind is FieldKind.MULTILINE:
168
195
  widget = TextArea("" if value is None else str(value), id=widget_id)
169
196
  widget.read_only = disabled
@@ -238,6 +265,11 @@ def _widget_value(widget: object) -> object:
238
265
  return widget.text
239
266
  if isinstance(widget, Checkbox):
240
267
  return widget.value
268
+ if isinstance(widget, OptionList):
269
+ if widget.highlighted is None:
270
+ return None
271
+ selected = widget.get_option_at_index(widget.highlighted)
272
+ return None if selected.id == "__groundskeeping_blank_choice__" else selected.id
241
273
  if isinstance(widget, Select):
242
274
  selected = widget.value
243
275
  return None if selected is Select.BLANK else selected
@@ -29,6 +29,7 @@ from groundskeeping.contracts.views import (
29
29
  TreeNode,
30
30
  TreeView,
31
31
  ViewAction,
32
+ WorkbenchLabels,
32
33
  )
33
34
  from groundskeeping.theme import node_label, status_style
34
35
  from groundskeeping.widgets.primitives import EmptyState, LoadingState
@@ -37,8 +38,9 @@ from groundskeeping.widgets.primitives import EmptyState, LoadingState
37
38
  class Workbench(Widget):
38
39
  """Shared page surface with navigation left and rows/detail right."""
39
40
 
40
- def __init__(self) -> None:
41
+ def __init__(self, labels: WorkbenchLabels | None = None) -> None:
41
42
  super().__init__(id="workbench")
43
+ self.labels = labels or WorkbenchLabels()
42
44
  self._loading_timer: Timer | None = None
43
45
  self._loading_frame = 0
44
46
  self._loading_view: LoadingView | None = None
@@ -54,7 +56,7 @@ class Workbench(Widget):
54
56
  with Horizontal(id="result-header"):
55
57
  yield Static("", id="result-status")
56
58
  yield Static(
57
- "Select a section to inspect it.", id="result-summary"
59
+ self.labels.initial_result_summary, id="result-summary"
58
60
  )
59
61
  with Horizontal(id="result-actions"):
60
62
  for index in range(MAX_VIEW_ACTIONS):
@@ -68,9 +70,9 @@ class Workbench(Widget):
68
70
  yield DataTable(id="context-table")
69
71
 
70
72
  def on_mount(self) -> None:
71
- self.query_one("#catalogue-panel").border_title = "Sections"
72
- self.query_one("#result-panel").border_title = "Rows"
73
- self.query_one("#context-panel").border_title = "Detail"
73
+ self.query_one("#catalogue-panel").border_title = self.labels.navigation_panel
74
+ self.query_one("#result-panel").border_title = self.labels.result_panel
75
+ self.query_one("#context-panel").border_title = self.labels.detail_panel
74
76
  self.catalogue.show_root = False
75
77
  self.catalogue.root.expand()
76
78
  self.catalogue.styles.display = "none"
@@ -284,27 +286,47 @@ class Workbench(Widget):
284
286
  self.query_one("#result-loading", LoadingState).hide_loading()
285
287
 
286
288
  def show_detail(self, detail: DetailView) -> None:
289
+ panel = self.query_one("#context-panel")
290
+ panel.border_subtitle = ""
287
291
  if isinstance(detail, TextView):
288
292
  self.query_one("#context-table", DataTable).styles.display = "none"
289
293
  context = self.query_one("#context", TextArea)
290
294
  context.styles.display = "block"
291
295
  context.load_text(detail.body)
296
+ panel.border_title = detail.title
292
297
  return
293
298
  if isinstance(detail, KeyValueView):
299
+ panel.border_title = detail.title
294
300
  self.show_context_table(detail.rows)
295
301
  return
302
+ if isinstance(detail, TableView):
303
+ self.show_detail_table(detail)
304
+ return
296
305
  raise TypeError(f"Unsupported detail view: {type(detail).__name__}")
297
306
 
307
+ def show_detail_table(self, detail: TableView) -> None:
308
+ self.query_one("#context", TextArea).styles.display = "none"
309
+ table = self.query_one("#context-table", DataTable)
310
+ table.styles.display = "block"
311
+ table.clear(columns=True)
312
+ table.add_columns(*detail.columns)
313
+ for row in detail.rows:
314
+ table.add_row(*row.cells, key=row.key)
315
+ self.query_one("#context-panel").border_title = detail.title
316
+ self.query_one("#context-panel").border_subtitle = f"{len(detail.rows)} rows"
317
+
298
318
  def show_context_table(
299
319
  self,
300
320
  rows: Iterable[tuple[str, str]],
301
321
  *,
302
- columns: tuple[str, str] = ("Field", "Value"),
322
+ columns: tuple[str, ...] | None = None,
303
323
  ) -> None:
304
324
  self.query_one("#context", TextArea).styles.display = "none"
305
325
  table = self.query_one("#context-table", DataTable)
306
326
  table.styles.display = "block"
307
327
  table.clear(columns=True)
308
- table.add_columns(*columns)
328
+ table.add_columns(
329
+ *(self.labels.key_value_columns if columns is None else columns)
330
+ )
309
331
  for label, value in rows:
310
332
  table.add_row(label, value)
@@ -3,22 +3,28 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from dataclasses import replace
5
5
 
6
+ import pytest
6
7
  from textual.widget import Widget
7
- from textual.widgets import Button
8
+ from textual.widgets import Button, DataTable
8
9
 
9
10
  from groundskeeping.app import OperatorApp, OperatorAppSpec
10
11
  from groundskeeping.contracts import (
11
12
  MAX_VIEW_ACTIONS,
12
13
  EmptyView,
14
+ KeyValueView,
13
15
  NavigationItem,
14
16
  PageContext,
15
17
  PageRegistration,
16
18
  PageRoute,
17
19
  SectionNavigation,
18
20
  SurfaceView,
21
+ TableRow,
22
+ TableView,
23
+ TextView,
19
24
  ViewAction,
20
25
  WizardResult,
21
26
  WizardResultStatus,
27
+ WorkbenchLabels,
22
28
  )
23
29
  from groundskeeping.demo import build_demo_spec
24
30
 
@@ -60,6 +66,35 @@ class _CountingPage(Widget):
60
66
  return None
61
67
 
62
68
 
69
+ class _NonWidgetPage:
70
+ def __init__(self, route: PageRoute) -> None:
71
+ self.route = route
72
+
73
+ def activate(self, context: PageContext) -> None:
74
+ return None
75
+
76
+ def deactivate(self, context: PageContext) -> None:
77
+ return None
78
+
79
+ def build_navigation(self, context: PageContext) -> SectionNavigation:
80
+ return SectionNavigation(items=())
81
+
82
+ def landing_view(self, context: PageContext) -> SurfaceView:
83
+ return EmptyView(title=self.route.label, message="render")
84
+
85
+ def navigation_selected(self, item: NavigationItem, context: PageContext) -> None:
86
+ return None
87
+
88
+ def action_selected(self, action_key: str, context: PageContext) -> None:
89
+ return None
90
+
91
+ def row_highlighted(self, row_key: str, context: PageContext) -> None:
92
+ return None
93
+
94
+ def row_selected(self, row_key: str, context: PageContext) -> None:
95
+ return None
96
+
97
+
63
98
  def test_demo_app_enters_textual_startup_context() -> None:
64
99
  async def run() -> None:
65
100
  app = OperatorApp(build_demo_spec())
@@ -91,6 +126,25 @@ def test_demo_app_enters_textual_startup_context() -> None:
91
126
  asyncio.run(run())
92
127
 
93
128
 
129
+ def test_operator_app_rejects_non_widget_pages() -> None:
130
+ async def run() -> None:
131
+ route = PageRoute("setup", "Setup", "Setup page")
132
+ app = OperatorApp(
133
+ OperatorAppSpec(
134
+ app_id="non-widget-test",
135
+ title="Non Widget Test",
136
+ subtitle=None,
137
+ pages=(PageRegistration(route, lambda context: _NonWidgetPage(route)),),
138
+ )
139
+ )
140
+
141
+ with pytest.raises(TypeError, match="must be a Textual Widget"):
142
+ async with app.run_test():
143
+ pass
144
+
145
+ asyncio.run(run())
146
+
147
+
94
148
  def test_single_page_hides_tabs_without_removing_page_registry() -> None:
95
149
  async def run() -> None:
96
150
  demo = build_demo_spec()
@@ -109,6 +163,46 @@ def test_single_page_hides_tabs_without_removing_page_registry() -> None:
109
163
  asyncio.run(run())
110
164
 
111
165
 
166
+ def test_app_spec_can_override_workbench_chrome_labels() -> None:
167
+ async def run() -> None:
168
+ demo = build_demo_spec()
169
+ spec = replace(
170
+ demo,
171
+ workbench_labels=WorkbenchLabels(
172
+ navigation_panel="Setup areas",
173
+ result_panel="Checks",
174
+ detail_panel="Inspector",
175
+ initial_result_summary="Choose a setup area.",
176
+ key_value_columns=("Setting", "Value"),
177
+ ),
178
+ )
179
+ app = OperatorApp(spec)
180
+
181
+ async with app.run_test() as pilot:
182
+ assert app.query_one("#result-panel").border_title == "Checks"
183
+ assert app.query_one("#context-panel").border_title == "Inspector"
184
+
185
+ app._workbench.show_navigation(SectionNavigation(items=()))
186
+ assert app.query_one("#catalogue-panel").border_title == "Sections"
187
+
188
+ app._workbench.show_context_table((("provider", "ollama"),))
189
+ await pilot.pause()
190
+
191
+ table = app.query_one("#context-table", DataTable)
192
+ assert tuple(str(column.label) for column in table.ordered_columns) == (
193
+ "Setting",
194
+ "Value",
195
+ )
196
+
197
+ app._workbench.show_context_table((), columns=())
198
+ await pilot.pause()
199
+
200
+ assert len(table.ordered_columns) == 0
201
+ await pilot.press("q")
202
+
203
+ asyncio.run(run())
204
+
205
+
112
206
  def test_wizard_result_refreshes_active_page_when_requested() -> None:
113
207
  async def run() -> None:
114
208
  active_route = PageRoute("setup", "Setup", "Setup page")
@@ -202,3 +296,58 @@ def test_workbench_clamps_extra_view_actions_without_crashing() -> None:
202
296
  await pilot.press("q")
203
297
 
204
298
  asyncio.run(run())
299
+
300
+
301
+ def test_workbench_renders_table_view_in_detail_pane() -> None:
302
+ async def run() -> None:
303
+ app = OperatorApp(build_demo_spec())
304
+
305
+ async with app.run_test(size=(120, 40)) as pilot:
306
+ detail = TableView(
307
+ title="Model inventory",
308
+ columns=("Model", "Context", "Status"),
309
+ rows=tuple(
310
+ TableRow(
311
+ key=f"model-{index}",
312
+ cells=(f"model-{index}", f"{index * 1000}", "available"),
313
+ )
314
+ for index in range(12)
315
+ ),
316
+ )
317
+
318
+ app._workbench.show_detail(detail)
319
+ await pilot.pause()
320
+
321
+ context = app.query_one("#context")
322
+ table = app.query_one("#context-table", DataTable)
323
+
324
+ assert context.styles.display == "none"
325
+ assert table.styles.display == "block"
326
+ assert table.row_count == 12
327
+ assert len(table.ordered_columns) == 3
328
+ assert app.query_one("#context-panel").border_title == "Model inventory"
329
+ assert app.query_one("#context-panel").border_subtitle == "12 rows"
330
+
331
+ app._workbench.show_detail(TextView("Model notes", "Use qwen locally."))
332
+ await pilot.pause()
333
+
334
+ assert context.styles.display == "block"
335
+ assert table.styles.display == "none"
336
+ assert app.query_one("#context-panel").border_title == "Model notes"
337
+ assert app.query_one("#context-panel").border_subtitle == ""
338
+
339
+ app._workbench.show_detail(
340
+ KeyValueView(
341
+ rows=(("Provider", "Ollama"),),
342
+ title="Model facts",
343
+ )
344
+ )
345
+ await pilot.pause()
346
+
347
+ assert context.styles.display == "none"
348
+ assert table.styles.display == "block"
349
+ assert app.query_one("#context-panel").border_title == "Model facts"
350
+ assert app.query_one("#context-panel").border_subtitle == ""
351
+ await pilot.press("q")
352
+
353
+ asyncio.run(run())
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ from collections.abc import Mapping
4
5
 
5
6
  import pytest
6
- from textual.widgets import Button
7
+ from textual.widgets import Button, OptionList
7
8
 
8
9
  from groundskeeping.app import OperatorApp
9
10
  from groundskeeping.configurator import (
@@ -14,19 +15,117 @@ from groundskeeping.configurator import (
14
15
  )
15
16
  from groundskeeping.contracts import (
16
17
  Choice,
18
+ ChoiceOption,
17
19
  ChoiceStep,
20
+ FieldKind,
18
21
  FieldSpec,
19
22
  FormStep,
20
23
  ReviewChange,
21
24
  ReviewStep,
22
25
  WizardDefinitionError,
26
+ WizardResult,
23
27
  WizardResultStatus,
24
28
  WizardReview,
25
29
  WizardSnapshot,
26
30
  WizardSpec,
31
+ WizardTransition,
27
32
  validate_wizard_steps,
28
33
  )
29
34
  from groundskeeping.demo import ConfigPage, _DemoConfigWizardController, build_demo_spec
35
+ from groundskeeping.widgets.wizard import WizardScreen
36
+
37
+
38
+ class _DynamicChoiceWizard:
39
+ spec = WizardSpec(
40
+ key="model-picker",
41
+ title="Pick a model",
42
+ purpose="Choose one model from inventory.",
43
+ apply_label="Apply",
44
+ )
45
+
46
+ def __init__(self) -> None:
47
+ self.submitted: dict[str, object] = {}
48
+ self.step = FormStep(
49
+ key="model",
50
+ title="Model inventory",
51
+ fields=(
52
+ FieldSpec(
53
+ key="model",
54
+ label="Model",
55
+ kind=FieldKind.CHOICE,
56
+ choices=tuple(
57
+ ChoiceOption(
58
+ value=f"model-{index:02d}",
59
+ label=f"Model {index:02d}",
60
+ description=f"{index * 1000} context",
61
+ )
62
+ for index in range(12)
63
+ ),
64
+ ),
65
+ ),
66
+ )
67
+ self.review_step = ReviewStep("review", "Review", WizardReview())
68
+
69
+ def start(self) -> WizardSnapshot:
70
+ return WizardSnapshot(
71
+ spec=self.spec,
72
+ step=self.step,
73
+ step_index=0,
74
+ step_count=2,
75
+ values={"model": "model-04"},
76
+ )
77
+
78
+ def submit(self, values: Mapping[str, object]) -> WizardTransition:
79
+ self.submitted = dict(values)
80
+ return WizardTransition(
81
+ WizardSnapshot(
82
+ spec=self.spec,
83
+ step=self.review_step,
84
+ step_index=1,
85
+ step_count=2,
86
+ can_back=True,
87
+ can_next=False,
88
+ can_apply=True,
89
+ )
90
+ )
91
+
92
+ def back(self) -> WizardSnapshot:
93
+ return self.start()
94
+
95
+ def review(self) -> WizardTransition:
96
+ return self.submit(self.submitted)
97
+
98
+ def apply(self) -> WizardResult:
99
+ return WizardResult(WizardResultStatus.APPLIED, "Applied.")
100
+
101
+ def cancel(self) -> WizardResult:
102
+ return WizardResult(WizardResultStatus.CANCELLED, "Cancelled.")
103
+
104
+
105
+ class _NoChoiceWizard(_DynamicChoiceWizard):
106
+ def __init__(self) -> None:
107
+ super().__init__()
108
+ self.step = FormStep(
109
+ key="model",
110
+ title="Model inventory",
111
+ fields=(
112
+ FieldSpec(
113
+ key="model",
114
+ label="Model",
115
+ kind=FieldKind.CHOICE,
116
+ choices=(),
117
+ ),
118
+ ),
119
+ )
120
+
121
+ def start(self) -> WizardSnapshot:
122
+ return WizardSnapshot(
123
+ spec=self.spec,
124
+ step=self.step,
125
+ step_index=0,
126
+ step_count=2,
127
+ values={"model": None},
128
+ )
30
129
 
31
130
 
32
131
  def test_wizard_definitions_require_unique_keys() -> None:
@@ -166,6 +265,53 @@ def test_demo_config_wizard_reports_stale_revision_conflict() -> None:
166
265
  assert result.refresh_pages == frozenset({"config"})
167
266
 
168
267
 
268
+ def test_wizard_choice_field_renders_dynamic_options_inline() -> None:
269
+ async def run() -> None:
270
+ controller = _DynamicChoiceWizard()
271
+ app = OperatorApp(build_demo_spec())
272
+
273
+ async with app.run_test(size=(120, 40)) as pilot:
274
+ app.push_screen(WizardScreen(controller))
275
+ await pilot.pause()
276
+
277
+ picker = app.screen.query_one("#wizard-field-0", OptionList)
278
+ assert picker.option_count == 12
279
+ assert picker.region.height > 1
280
+ assert picker.highlighted == 4
281
+
282
+ picker.focus()
283
+ await pilot.press("down")
284
+ await pilot.pause()
285
+
286
+ assert picker.highlighted == 5
287
+
288
+ await pilot.click("#wizard-next")
289
+ await pilot.pause()
290
+
291
+ assert controller.submitted["model"] == "model-05"
292
+ await pilot.press("escape")
293
+ await pilot.press("q")
294
+
295
+ asyncio.run(run())
296
+
297
+
298
+ def test_wizard_choice_field_handles_empty_required_choices() -> None:
299
+ async def run() -> None:
300
+ app = OperatorApp(build_demo_spec())
301
+
302
+ async with app.run_test(size=(120, 40)) as pilot:
303
+ app.push_screen(WizardScreen(_NoChoiceWizard()))
304
+ await pilot.pause()
305
+
306
+ picker = app.screen.query_one("#wizard-field-0", OptionList)
307
+ assert picker.option_count == 1
308
+ assert picker.highlighted == 0
309
+ await pilot.press("escape")
310
+ await pilot.press("q")
311
+
312
+ asyncio.run(run())
313
+
314
+
169
315
  def test_config_draft_and_apply_intent_are_safe_and_revision_aware() -> None:
170
316
  target = ConfigTarget(kind="database", key="metadata", title="metadata")
171
317
  draft = ConfigDraft(
File without changes