jstdata 0.2.0__py3-none-any.whl
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.
- jstdata/__init__.py +13 -0
- jstdata/agent_guide.py +530 -0
- jstdata/cli.py +682 -0
- jstdata/client.py +543 -0
- jstdata/models.py +161 -0
- jstdata/session.py +251 -0
- jstdata/utils.py +103 -0
- jstdata/workflows/__init__.py +88 -0
- jstdata/workflows/base.py +356 -0
- jstdata/workflows/bundled/__init__.py +1 -0
- jstdata/workflows/bundled/tutorial.yaml +14 -0
- jstdata/workflows/bundled/tutorial_script.yaml +45 -0
- jstdata/workflows/chart.py +357 -0
- jstdata/workflows/console.py +560 -0
- jstdata/workflows/discover.py +502 -0
- jstdata/workflows/export.py +171 -0
- jstdata/workflows/find.py +386 -0
- jstdata/workflows/host.py +255 -0
- jstdata/workflows/rank.py +658 -0
- jstdata/workflows/session_manager.py +277 -0
- jstdata/workflows/store.py +308 -0
- jstdata/workflows/tutorial.py +163 -0
- jstdata/workflows/tutorial_host.py +178 -0
- jstdata-0.2.0.dist-info/METADATA +227 -0
- jstdata-0.2.0.dist-info/RECORD +28 -0
- jstdata-0.2.0.dist-info/WHEEL +4 -0
- jstdata-0.2.0.dist-info/entry_points.txt +3 -0
- jstdata-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
"""Host-level find modal: search metrics or entities and add to the session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from textual import on, work
|
|
10
|
+
from textual.app import ComposeResult
|
|
11
|
+
from textual.binding import Binding
|
|
12
|
+
from textual.containers import Horizontal, Vertical
|
|
13
|
+
from textual.screen import ModalScreen
|
|
14
|
+
from textual.widgets import Input, Label, ListItem, ListView, Static
|
|
15
|
+
|
|
16
|
+
from ..client import JSTDataClient
|
|
17
|
+
from ..models import Entity, Metric
|
|
18
|
+
from ..session import Session
|
|
19
|
+
|
|
20
|
+
_PLACEHOLDER = "Highlight a result and press i to inspect."
|
|
21
|
+
FindMode = Literal["metric", "entity"]
|
|
22
|
+
_MODES: tuple[FindMode, ...] = ("metric", "entity")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _resource_type(resource: Any) -> str:
|
|
26
|
+
if isinstance(resource, Entity):
|
|
27
|
+
return "entity"
|
|
28
|
+
if isinstance(resource, Metric):
|
|
29
|
+
return "metric"
|
|
30
|
+
return "metric"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resource_label(resource: Any) -> str:
|
|
34
|
+
return (
|
|
35
|
+
getattr(resource, "label", None)
|
|
36
|
+
or getattr(resource, "name", None)
|
|
37
|
+
or getattr(resource, "id", "Unknown")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def next_find_mode(mode: FindMode) -> FindMode:
|
|
42
|
+
"""Cycle metric → entity → metric."""
|
|
43
|
+
return "entity" if mode == "metric" else "metric"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class FindResultRow(ListItem):
|
|
47
|
+
"""One search hit in the find modal."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, resource: Any) -> None:
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.resource = resource
|
|
52
|
+
self.resource_type = _resource_type(resource)
|
|
53
|
+
|
|
54
|
+
def compose(self) -> ComposeResult:
|
|
55
|
+
name = _resource_label(self.resource)
|
|
56
|
+
source = getattr(self.resource, "source", "") or ""
|
|
57
|
+
yield Label(
|
|
58
|
+
f"{self.resource_type.upper():<8} {name} [{self.resource.id}]"
|
|
59
|
+
+ (f" {source}" if source else ""),
|
|
60
|
+
classes="find-row-label",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FindModal(ModalScreen[None]):
|
|
65
|
+
"""Shared find surface: metric/entity search, add to session, inspect."""
|
|
66
|
+
|
|
67
|
+
DEFAULT_CSS = """
|
|
68
|
+
FindModal {
|
|
69
|
+
align: center middle;
|
|
70
|
+
background: rgba(0, 0, 0, 0.7);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#find-container {
|
|
74
|
+
width: 90%;
|
|
75
|
+
height: 80%;
|
|
76
|
+
max-width: 140;
|
|
77
|
+
max-height: 40;
|
|
78
|
+
border: thick #4ade80;
|
|
79
|
+
background: #111111;
|
|
80
|
+
padding: 1 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#find-title {
|
|
84
|
+
text-style: bold;
|
|
85
|
+
color: #4ade80;
|
|
86
|
+
text-align: center;
|
|
87
|
+
height: 1;
|
|
88
|
+
margin-bottom: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#find-mode-bar {
|
|
92
|
+
height: 1;
|
|
93
|
+
text-align: center;
|
|
94
|
+
margin-bottom: 1;
|
|
95
|
+
color: #888;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#find-search-input {
|
|
99
|
+
width: 100%;
|
|
100
|
+
background: #0a0a0a;
|
|
101
|
+
border: solid #333;
|
|
102
|
+
margin-bottom: 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#find-body {
|
|
106
|
+
height: 1fr;
|
|
107
|
+
width: 100%;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#find-results-pane {
|
|
111
|
+
width: 1fr;
|
|
112
|
+
height: 100%;
|
|
113
|
+
border-right: solid #333;
|
|
114
|
+
padding-right: 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#find-inspect-pane {
|
|
118
|
+
width: 1fr;
|
|
119
|
+
height: 100%;
|
|
120
|
+
padding-left: 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.find-pane-header {
|
|
124
|
+
color: #4ade80;
|
|
125
|
+
text-style: bold;
|
|
126
|
+
height: 1;
|
|
127
|
+
margin-bottom: 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#find-results-list {
|
|
131
|
+
height: 1fr;
|
|
132
|
+
width: 100%;
|
|
133
|
+
background: #0a0a0a;
|
|
134
|
+
border: solid #222;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
FindResultRow {
|
|
138
|
+
height: 1;
|
|
139
|
+
min-height: 1;
|
|
140
|
+
max-height: 1;
|
|
141
|
+
width: 100%;
|
|
142
|
+
padding: 0 1;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
FindResultRow:hover,
|
|
147
|
+
FindResultRow.-highlighted {
|
|
148
|
+
background: #1a3a1a;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.find-row-label {
|
|
152
|
+
width: 100%;
|
|
153
|
+
height: 1;
|
|
154
|
+
color: #e0e0e0;
|
|
155
|
+
overflow: hidden;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
ListView > ListItem {
|
|
159
|
+
height: 1;
|
|
160
|
+
min-height: 1;
|
|
161
|
+
max-height: 1;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#find-inspect-payload {
|
|
165
|
+
height: 1fr;
|
|
166
|
+
width: 100%;
|
|
167
|
+
background: #0a0a0a;
|
|
168
|
+
border: solid #222;
|
|
169
|
+
color: #e0e0e0;
|
|
170
|
+
padding: 1;
|
|
171
|
+
overflow-y: auto;
|
|
172
|
+
overflow-x: auto;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#find-hint {
|
|
176
|
+
color: #888;
|
|
177
|
+
height: 1;
|
|
178
|
+
margin-top: 1;
|
|
179
|
+
dock: bottom;
|
|
180
|
+
}
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
BINDINGS = [
|
|
184
|
+
Binding("escape", "dismiss", "Close"),
|
|
185
|
+
Binding(
|
|
186
|
+
"shift+tab",
|
|
187
|
+
"toggle_mode",
|
|
188
|
+
"Toggle metric/entity",
|
|
189
|
+
priority=True,
|
|
190
|
+
),
|
|
191
|
+
Binding("i", "inspect", "Inspect"),
|
|
192
|
+
Binding("j", "cursor_down", "Down", show=False),
|
|
193
|
+
Binding("k", "cursor_up", "Up", show=False),
|
|
194
|
+
Binding("down", "cursor_down", "Down", show=False),
|
|
195
|
+
Binding("up", "cursor_up", "Up", show=False),
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
def __init__(
|
|
199
|
+
self,
|
|
200
|
+
client: JSTDataClient,
|
|
201
|
+
session: Session,
|
|
202
|
+
mode: FindMode = "metric",
|
|
203
|
+
) -> None:
|
|
204
|
+
super().__init__()
|
|
205
|
+
self.client = client
|
|
206
|
+
self.session = session
|
|
207
|
+
self.mode: FindMode = mode if mode in _MODES else "metric"
|
|
208
|
+
self.search_task: asyncio.Task[None] | None = None
|
|
209
|
+
self._inspected_id: str | None = None
|
|
210
|
+
|
|
211
|
+
def compose(self) -> ComposeResult:
|
|
212
|
+
with Vertical(id="find-container"):
|
|
213
|
+
yield Label("FIND // ADD TO SESSION", id="find-title", markup=True)
|
|
214
|
+
yield Label("", id="find-mode-bar", markup=True)
|
|
215
|
+
yield Input(
|
|
216
|
+
placeholder="Search metrics…",
|
|
217
|
+
id="find-search-input",
|
|
218
|
+
)
|
|
219
|
+
with Horizontal(id="find-body"):
|
|
220
|
+
with Vertical(id="find-results-pane"):
|
|
221
|
+
yield Label("RESULTS", classes="find-pane-header")
|
|
222
|
+
yield ListView(id="find-results-list")
|
|
223
|
+
with Vertical(id="find-inspect-pane"):
|
|
224
|
+
yield Label("INSPECT", classes="find-pane-header")
|
|
225
|
+
yield Static(_PLACEHOLDER, id="find-inspect-payload", markup=False)
|
|
226
|
+
yield Label("", id="find-hint", markup=True)
|
|
227
|
+
|
|
228
|
+
def on_mount(self) -> None:
|
|
229
|
+
self._refresh_mode_chrome()
|
|
230
|
+
self.query_one("#find-search-input", Input).focus()
|
|
231
|
+
|
|
232
|
+
def _refresh_mode_chrome(self) -> None:
|
|
233
|
+
if self.mode == "metric":
|
|
234
|
+
self.query_one("#find-title").update(
|
|
235
|
+
"FIND // [bold #38bdf8]METRIC[/]"
|
|
236
|
+
)
|
|
237
|
+
mode_bar = (
|
|
238
|
+
"[bold #38bdf8]METRIC[/] · [dim #fbbf24]ENTITY[/] "
|
|
239
|
+
"(shift+tab to switch)"
|
|
240
|
+
)
|
|
241
|
+
else:
|
|
242
|
+
self.query_one("#find-title").update(
|
|
243
|
+
"FIND // [bold #fbbf24]ENTITY[/]"
|
|
244
|
+
)
|
|
245
|
+
mode_bar = (
|
|
246
|
+
"[dim #38bdf8]METRIC[/] · [bold #fbbf24]ENTITY[/] "
|
|
247
|
+
"(shift+tab to switch)"
|
|
248
|
+
)
|
|
249
|
+
self.query_one("#find-mode-bar").update(mode_bar)
|
|
250
|
+
placeholder = (
|
|
251
|
+
"Search metrics…" if self.mode == "metric" else "Search entities…"
|
|
252
|
+
)
|
|
253
|
+
self.query_one("#find-search-input", Input).placeholder = placeholder
|
|
254
|
+
other = next_find_mode(self.mode)
|
|
255
|
+
self.query_one("#find-hint").update(
|
|
256
|
+
f"[bold]shift+tab[/bold] switch to {other} // "
|
|
257
|
+
"[bold]tab[/bold]/[bold]enter[/bold] results // "
|
|
258
|
+
"[bold]i[/bold] inspect // "
|
|
259
|
+
"[bold]j[/bold]/[bold]k[/bold] // [bold]esc[/bold] close"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def _show_placeholder(self) -> None:
|
|
263
|
+
self._inspected_id = None
|
|
264
|
+
self.query_one("#find-inspect-payload", Static).update(_PLACEHOLDER)
|
|
265
|
+
|
|
266
|
+
def _highlighted_row(self) -> FindResultRow | None:
|
|
267
|
+
list_view = self.query_one("#find-results-list", ListView)
|
|
268
|
+
child = list_view.highlighted_child
|
|
269
|
+
if isinstance(child, FindResultRow):
|
|
270
|
+
return child
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
def _add_resource(self, resource: Any) -> bool:
|
|
274
|
+
if isinstance(resource, Metric):
|
|
275
|
+
added = self.session.add_metric(resource.id)
|
|
276
|
+
elif isinstance(resource, Entity):
|
|
277
|
+
added = self.session.add_entity(resource.id)
|
|
278
|
+
elif resource.id in self.session.resource_ids():
|
|
279
|
+
return False
|
|
280
|
+
else:
|
|
281
|
+
return False
|
|
282
|
+
if added and hasattr(self.app, "remember_label"):
|
|
283
|
+
self.app.remember_label(resource.id, _resource_label(resource))
|
|
284
|
+
return added
|
|
285
|
+
|
|
286
|
+
def action_toggle_mode(self) -> None:
|
|
287
|
+
self.mode = next_find_mode(self.mode)
|
|
288
|
+
self._refresh_mode_chrome()
|
|
289
|
+
self.query_one("#find-results-list", ListView).clear()
|
|
290
|
+
self._show_placeholder()
|
|
291
|
+
query = self.query_one("#find-search-input", Input).value.strip()
|
|
292
|
+
if self.search_task:
|
|
293
|
+
self.search_task.cancel()
|
|
294
|
+
if len(query) >= 2:
|
|
295
|
+
self.search_task = asyncio.create_task(self._do_search(query))
|
|
296
|
+
|
|
297
|
+
@on(Input.Changed, "#find-search-input")
|
|
298
|
+
def on_search_changed(self, event: Input.Changed) -> None:
|
|
299
|
+
if self.search_task:
|
|
300
|
+
self.search_task.cancel()
|
|
301
|
+
if len(event.value.strip()) < 2:
|
|
302
|
+
self.query_one("#find-results-list", ListView).clear()
|
|
303
|
+
self._show_placeholder()
|
|
304
|
+
return
|
|
305
|
+
self.search_task = asyncio.create_task(self._do_search(event.value.strip()))
|
|
306
|
+
|
|
307
|
+
async def _do_search(self, query: str) -> None:
|
|
308
|
+
try:
|
|
309
|
+
await asyncio.sleep(0.3)
|
|
310
|
+
if self.mode == "entity":
|
|
311
|
+
results = await asyncio.to_thread(
|
|
312
|
+
self.client.search_entities, query, limit=20
|
|
313
|
+
)
|
|
314
|
+
else:
|
|
315
|
+
results = await asyncio.to_thread(
|
|
316
|
+
self.client.search_metrics, query, limit=20
|
|
317
|
+
)
|
|
318
|
+
list_view = self.query_one("#find-results-list", ListView)
|
|
319
|
+
list_view.clear()
|
|
320
|
+
self._show_placeholder()
|
|
321
|
+
for r in results:
|
|
322
|
+
list_view.append(FindResultRow(r))
|
|
323
|
+
except asyncio.CancelledError:
|
|
324
|
+
pass
|
|
325
|
+
except Exception as e:
|
|
326
|
+
self.notify(f"Search error: {e}", severity="error")
|
|
327
|
+
|
|
328
|
+
@on(Input.Submitted, "#find-search-input")
|
|
329
|
+
def on_search_submit(self) -> None:
|
|
330
|
+
self.query_one("#find-results-list", ListView).focus()
|
|
331
|
+
|
|
332
|
+
@on(ListView.Selected, "#find-results-list")
|
|
333
|
+
def on_result_selected(self, event: ListView.Selected) -> None:
|
|
334
|
+
if not isinstance(event.item, FindResultRow):
|
|
335
|
+
return
|
|
336
|
+
resource = event.item.resource
|
|
337
|
+
if not self._add_resource(resource):
|
|
338
|
+
self.notify(f"{resource.id} is already in session", severity="warning")
|
|
339
|
+
return
|
|
340
|
+
self.notify(f"Added {resource.id} to session")
|
|
341
|
+
|
|
342
|
+
@on(ListView.Highlighted, "#find-results-list")
|
|
343
|
+
def on_highlight_changed(self, event: ListView.Highlighted) -> None:
|
|
344
|
+
row = event.item if isinstance(event.item, FindResultRow) else None
|
|
345
|
+
if row is None or row.resource.id != self._inspected_id:
|
|
346
|
+
self._show_placeholder()
|
|
347
|
+
|
|
348
|
+
def action_inspect(self) -> None:
|
|
349
|
+
if isinstance(self.focused, Input):
|
|
350
|
+
return
|
|
351
|
+
row = self._highlighted_row()
|
|
352
|
+
if row is None:
|
|
353
|
+
self.notify("Select a result to inspect", severity="warning")
|
|
354
|
+
return
|
|
355
|
+
self.query_one("#find-inspect-payload", Static).update("Fetching...")
|
|
356
|
+
self._fetch_payload(row.resource_type, row.resource.id)
|
|
357
|
+
|
|
358
|
+
@work(exclusive=True)
|
|
359
|
+
async def _fetch_payload(self, resource_type: str, resource_id: str) -> None:
|
|
360
|
+
try:
|
|
361
|
+
data = await asyncio.to_thread(
|
|
362
|
+
self.client.make_request, f"{resource_type}/{resource_id}"
|
|
363
|
+
)
|
|
364
|
+
text = json.dumps(data, indent=2, default=str)
|
|
365
|
+
self._inspected_id = resource_id
|
|
366
|
+
self.query_one("#find-inspect-payload", Static).update(text)
|
|
367
|
+
except Exception as e:
|
|
368
|
+
self._inspected_id = None
|
|
369
|
+
self.query_one("#find-inspect-payload", Static).update(
|
|
370
|
+
f"Inspect failed: {e}"
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def action_cursor_down(self) -> None:
|
|
374
|
+
focused = self.focused
|
|
375
|
+
if isinstance(focused, Input):
|
|
376
|
+
return
|
|
377
|
+
self.query_one("#find-results-list", ListView).action_cursor_down()
|
|
378
|
+
|
|
379
|
+
def action_cursor_up(self) -> None:
|
|
380
|
+
focused = self.focused
|
|
381
|
+
if isinstance(focused, Input):
|
|
382
|
+
return
|
|
383
|
+
self.query_one("#find-results-list", ListView).action_cursor_up()
|
|
384
|
+
|
|
385
|
+
def action_dismiss(self) -> None:
|
|
386
|
+
self.dismiss()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Workflow host: shared pipeline runtime and universal modals wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
|
+
from textual.screen import ModalScreen
|
|
9
|
+
from textual.widgets import Input, Label
|
|
10
|
+
|
|
11
|
+
from ..client import JSTDataClient
|
|
12
|
+
from .base import (
|
|
13
|
+
ResolvedStep,
|
|
14
|
+
default_session_path,
|
|
15
|
+
hydrate_labels,
|
|
16
|
+
load_session_or_empty,
|
|
17
|
+
)
|
|
18
|
+
from .export import ExportModal
|
|
19
|
+
from .find import FindModal
|
|
20
|
+
from .session_manager import SessionManagerModal
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HelpScreen(ModalScreen):
|
|
24
|
+
"""Step-specific keybindings from the current StepSpec."""
|
|
25
|
+
|
|
26
|
+
DEFAULT_CSS = """
|
|
27
|
+
HelpScreen {
|
|
28
|
+
align: center middle;
|
|
29
|
+
background: rgba(0, 0, 0, 0.7);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#help-container {
|
|
33
|
+
width: 60;
|
|
34
|
+
height: auto;
|
|
35
|
+
max-height: 90%;
|
|
36
|
+
border: thick #4ade80;
|
|
37
|
+
background: #111111;
|
|
38
|
+
padding: 1 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#help-title {
|
|
42
|
+
text-style: bold;
|
|
43
|
+
color: #4ade80;
|
|
44
|
+
margin-bottom: 1;
|
|
45
|
+
text-align: center;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#help-note {
|
|
49
|
+
color: #888;
|
|
50
|
+
margin-bottom: 1;
|
|
51
|
+
text-align: center;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.key-row {
|
|
55
|
+
height: auto;
|
|
56
|
+
margin-bottom: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.key-col {
|
|
60
|
+
color: #4ade80;
|
|
61
|
+
text-style: bold;
|
|
62
|
+
width: 16;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.desc-col {
|
|
66
|
+
color: #e0e0e0;
|
|
67
|
+
width: 1fr;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#help-footer {
|
|
71
|
+
color: #888;
|
|
72
|
+
margin-top: 1;
|
|
73
|
+
height: 1;
|
|
74
|
+
}
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
BINDINGS = [
|
|
78
|
+
Binding("escape", "dismiss", "Close"),
|
|
79
|
+
Binding("question_mark", "dismiss", "Close", show=False),
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
def __init__(self, step_name: str, bindings: list) -> None:
|
|
83
|
+
super().__init__()
|
|
84
|
+
self.step_name = step_name
|
|
85
|
+
self.step_bindings = bindings
|
|
86
|
+
|
|
87
|
+
def compose(self) -> ComposeResult:
|
|
88
|
+
with Vertical(id="help-container"):
|
|
89
|
+
yield Label(f"{self.step_name.upper()} // KEYBINDINGS", id="help-title")
|
|
90
|
+
yield Label(
|
|
91
|
+
"Universal keys stay in the status bar (s/f/e/n/p/q).",
|
|
92
|
+
id="help-note",
|
|
93
|
+
)
|
|
94
|
+
if not self.step_bindings:
|
|
95
|
+
yield Label("(no step-specific bindings)", classes="desc-col")
|
|
96
|
+
else:
|
|
97
|
+
for b in self.step_bindings:
|
|
98
|
+
if not getattr(b, "show_in_help", True):
|
|
99
|
+
continue
|
|
100
|
+
yield Horizontal(
|
|
101
|
+
Label(b.key, classes="key-col"),
|
|
102
|
+
Label(b.description, classes="desc-col"),
|
|
103
|
+
classes="key-row",
|
|
104
|
+
)
|
|
105
|
+
yield Label("[bold]esc[/bold] / [bold]?[/bold] close", id="help-footer", markup=True)
|
|
106
|
+
|
|
107
|
+
def action_dismiss(self) -> None:
|
|
108
|
+
self.dismiss()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class WorkflowHost(App):
|
|
112
|
+
"""One-process host for a pipeline of steps sharing a Session."""
|
|
113
|
+
|
|
114
|
+
CSS = """
|
|
115
|
+
Screen {
|
|
116
|
+
background: #0a0a0a;
|
|
117
|
+
color: #e0e0e0;
|
|
118
|
+
}
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
BINDINGS = [
|
|
122
|
+
Binding("q", "quit", "Quit"),
|
|
123
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
124
|
+
Binding("escape", "back", "Back"),
|
|
125
|
+
Binding("question_mark", "show_help", "Step help", key_display="?"),
|
|
126
|
+
Binding("s", "session", "Session"),
|
|
127
|
+
Binding("f", "find", "Find"),
|
|
128
|
+
Binding("e", "export", "Export"),
|
|
129
|
+
Binding("n", "next_step", "Next"),
|
|
130
|
+
Binding("p", "prev_step", "Prev"),
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
client: JSTDataClient,
|
|
136
|
+
steps: list[ResolvedStep],
|
|
137
|
+
session_path: str | None = None,
|
|
138
|
+
output_path: str | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
super().__init__()
|
|
141
|
+
if not steps:
|
|
142
|
+
raise ValueError("WorkflowHost requires at least one step")
|
|
143
|
+
self.client = client
|
|
144
|
+
self.steps = steps
|
|
145
|
+
self.step_index = 0
|
|
146
|
+
prefix = "-".join(s.spec.id for s in steps)
|
|
147
|
+
self.output_path = output_path or default_session_path(prefix)
|
|
148
|
+
self.session = load_session_or_empty(session_path)
|
|
149
|
+
self.labels: dict[str, str] = {}
|
|
150
|
+
hydrate_labels(self.client, self.session, self.labels)
|
|
151
|
+
|
|
152
|
+
def remember_label(self, resource_id: str, label: str) -> None:
|
|
153
|
+
"""Cache a display label for UI (session remains source of truth)."""
|
|
154
|
+
self.labels[resource_id] = label or resource_id
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def step_count(self) -> int:
|
|
158
|
+
return len(self.steps)
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def current_step_id(self) -> str:
|
|
162
|
+
return self.steps[self.step_index].spec.id
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def current_step_spec(self):
|
|
166
|
+
return self.steps[self.step_index].spec
|
|
167
|
+
|
|
168
|
+
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
|
|
169
|
+
# Let printable host keys fall through to focused Inputs (search, path, etc.).
|
|
170
|
+
if action in {"session", "find", "export", "next_step", "prev_step", "show_help"}:
|
|
171
|
+
if isinstance(self.focused, Input):
|
|
172
|
+
return False
|
|
173
|
+
return True
|
|
174
|
+
|
|
175
|
+
def on_mount(self) -> None:
|
|
176
|
+
self._push_current_step()
|
|
177
|
+
|
|
178
|
+
def _make_current_screen(self):
|
|
179
|
+
resolved = self.steps[self.step_index]
|
|
180
|
+
return resolved.spec.create_screen(
|
|
181
|
+
self.client,
|
|
182
|
+
self.session,
|
|
183
|
+
**resolved.kwargs,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _push_current_step(self) -> None:
|
|
187
|
+
self.push_screen(self._make_current_screen())
|
|
188
|
+
|
|
189
|
+
def _pop_overlays(self) -> None:
|
|
190
|
+
"""Dismiss modals. Keep the default screen plus the current step."""
|
|
191
|
+
while len(self.screen_stack) > 2:
|
|
192
|
+
self.pop_screen()
|
|
193
|
+
|
|
194
|
+
def action_next_step(self) -> None:
|
|
195
|
+
if self.step_index >= len(self.steps) - 1:
|
|
196
|
+
self.notify("Last step in the pipeline", severity="warning")
|
|
197
|
+
return
|
|
198
|
+
self._pop_overlays()
|
|
199
|
+
self.step_index += 1
|
|
200
|
+
self.switch_screen(self._make_current_screen())
|
|
201
|
+
|
|
202
|
+
def action_prev_step(self) -> None:
|
|
203
|
+
if self.step_index <= 0:
|
|
204
|
+
self.notify("First step in the pipeline", severity="warning")
|
|
205
|
+
return
|
|
206
|
+
self._pop_overlays()
|
|
207
|
+
self.step_index -= 1
|
|
208
|
+
self.switch_screen(self._make_current_screen())
|
|
209
|
+
|
|
210
|
+
def action_session(self) -> None:
|
|
211
|
+
"""Shared session manager: remove / inspect staged resources."""
|
|
212
|
+
self.push_screen(
|
|
213
|
+
SessionManagerModal(self.client, self.session, self.labels)
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def action_find(self) -> None:
|
|
217
|
+
"""Shared find: search catalog and add resources to the session."""
|
|
218
|
+
self.push_screen(FindModal(self.client, self.session))
|
|
219
|
+
|
|
220
|
+
def action_export(self) -> None:
|
|
221
|
+
"""Shared export: copy Python/CLI or write session file."""
|
|
222
|
+
self.push_screen(ExportModal(self.session, default_path=self.output_path))
|
|
223
|
+
|
|
224
|
+
def action_inspect(self) -> None:
|
|
225
|
+
screen = self.screen
|
|
226
|
+
if hasattr(screen, "action_inspect"):
|
|
227
|
+
screen.action_inspect()
|
|
228
|
+
|
|
229
|
+
def action_back(self) -> None:
|
|
230
|
+
"""Leave step-local filter / inspector focus when applicable."""
|
|
231
|
+
focused = self.focused
|
|
232
|
+
screen = self.screen
|
|
233
|
+
if getattr(focused, "id", None) in (
|
|
234
|
+
"metrics-filter",
|
|
235
|
+
"search-input",
|
|
236
|
+
) and hasattr(screen, "action_leave_filter"):
|
|
237
|
+
screen.action_leave_filter()
|
|
238
|
+
return
|
|
239
|
+
if focused and getattr(focused, "id", None) in (
|
|
240
|
+
"inspector-search-input",
|
|
241
|
+
"inspector-results-list",
|
|
242
|
+
):
|
|
243
|
+
try:
|
|
244
|
+
screen.query_one("#search-input", Input).focus()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
def action_show_help(self) -> None:
|
|
249
|
+
"""Show step-specific keybindings from the current StepSpec."""
|
|
250
|
+
spec = self.current_step_spec
|
|
251
|
+
self.push_screen(HelpScreen(spec.name, list(spec.bindings)))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# Back-compat alias
|
|
255
|
+
JSTDataApp = WorkflowHost
|