dlab-cli 0.1.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.
- dlab/__init__.py +6 -0
- dlab/cli.py +1075 -0
- dlab/config.py +190 -0
- dlab/create_dpack.py +1096 -0
- dlab/create_dpack_wizard.py +1471 -0
- dlab/create_parallel_agent_wizard.py +582 -0
- dlab/data/__init__.py +0 -0
- dlab/data/models.json +1793 -0
- dlab/docker.py +591 -0
- dlab/local.py +269 -0
- dlab/model_fallback.py +360 -0
- dlab/parallel_tool.py +18 -0
- dlab/session.py +389 -0
- dlab/timeline.py +684 -0
- dlab/tui/__init__.py +9 -0
- dlab/tui/app.py +664 -0
- dlab/tui/log_watcher.py +208 -0
- dlab/tui/models.py +438 -0
- dlab/tui/widgets/__init__.py +18 -0
- dlab/tui/widgets/agent_list.py +170 -0
- dlab/tui/widgets/artifacts_pane.py +618 -0
- dlab/tui/widgets/log_view.py +505 -0
- dlab/tui/widgets/search_popup.py +151 -0
- dlab/tui/widgets/status_bar.py +106 -0
- dlab_cli-0.1.0.dist-info/METADATA +237 -0
- dlab_cli-0.1.0.dist-info/RECORD +30 -0
- dlab_cli-0.1.0.dist-info/WHEEL +5 -0
- dlab_cli-0.1.0.dist-info/entry_points.txt +2 -0
- dlab_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- dlab_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1471 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Textual TUI wizard for creating a new decision-pack directory.
|
|
3
|
+
|
|
4
|
+
Multi-screen wizard that collects configuration and calls generate_dpack().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import shutil
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from textual import work
|
|
12
|
+
from textual.actions import SkipAction
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.binding import Binding
|
|
15
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
16
|
+
from textual.content import Content
|
|
17
|
+
from textual.screen import Screen
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FormScroll(VerticalScroll, can_focus=False):
|
|
21
|
+
"""VerticalScroll that doesn't consume arrow keys.
|
|
22
|
+
|
|
23
|
+
Overrides scroll actions to raise SkipAction, so arrow keys pass through
|
|
24
|
+
to app-level focus navigation. Still scrolls into view automatically
|
|
25
|
+
when children receive focus.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def action_scroll_up(self) -> None:
|
|
29
|
+
raise SkipAction()
|
|
30
|
+
|
|
31
|
+
def action_scroll_down(self) -> None:
|
|
32
|
+
raise SkipAction()
|
|
33
|
+
|
|
34
|
+
def action_scroll_left(self) -> None:
|
|
35
|
+
raise SkipAction()
|
|
36
|
+
|
|
37
|
+
def action_scroll_right(self) -> None:
|
|
38
|
+
raise SkipAction()
|
|
39
|
+
from textual.style import Style
|
|
40
|
+
from textual.widgets import (
|
|
41
|
+
Button,
|
|
42
|
+
Checkbox,
|
|
43
|
+
Input,
|
|
44
|
+
Label,
|
|
45
|
+
ListItem,
|
|
46
|
+
ListView,
|
|
47
|
+
OptionList,
|
|
48
|
+
Static,
|
|
49
|
+
)
|
|
50
|
+
from textual.widgets.option_list import Option
|
|
51
|
+
|
|
52
|
+
class BackButton(Button, can_focus=False):
|
|
53
|
+
"""Back button that is clickable but skipped in focus/Tab navigation."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
from dlab.create_dpack import (
|
|
57
|
+
CONFIGURABLE_PERMISSIONS,
|
|
58
|
+
HIGH_IMPACT_PERMISSION_COUNT,
|
|
59
|
+
KNOWN_BASE_IMAGES,
|
|
60
|
+
PACKAGE_MANAGER_BASE_IMAGES,
|
|
61
|
+
ask_skills,
|
|
62
|
+
filter_models,
|
|
63
|
+
generate_dpack,
|
|
64
|
+
get_model_list,
|
|
65
|
+
validate_dpack_name,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Custom checkbox with ▢/▣ glyphs
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
class DpackCheckbox(Checkbox):
|
|
74
|
+
"""Checkbox with ▢ (unchecked) and ▣ (checked) glyphs."""
|
|
75
|
+
|
|
76
|
+
BUTTON_LEFT: str = ""
|
|
77
|
+
BUTTON_RIGHT: str = ""
|
|
78
|
+
|
|
79
|
+
BINDINGS = [
|
|
80
|
+
Binding("tab", "tab_out", show=False),
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def _button(self) -> Content:
|
|
85
|
+
button_style = self.get_visual_style("toggle--button")
|
|
86
|
+
glyph: str = "▣" if self.value else "▢"
|
|
87
|
+
return Content.assemble((glyph, button_style))
|
|
88
|
+
|
|
89
|
+
def action_tab_out(self) -> None:
|
|
90
|
+
"""Tab jumps out of cb-group container to next element outside."""
|
|
91
|
+
# Walk up to find cb-group parent
|
|
92
|
+
cb_group = self.parent
|
|
93
|
+
while cb_group and not cb_group.has_class("cb-group"):
|
|
94
|
+
cb_group = cb_group.parent
|
|
95
|
+
if cb_group:
|
|
96
|
+
# Find first focusable widget after self that is NOT inside this cb-group
|
|
97
|
+
found_self: bool = False
|
|
98
|
+
for widget in self.screen.focus_chain:
|
|
99
|
+
if widget is self:
|
|
100
|
+
found_self = True
|
|
101
|
+
continue
|
|
102
|
+
if found_self and cb_group not in widget.ancestors:
|
|
103
|
+
widget.focus()
|
|
104
|
+
return
|
|
105
|
+
# Fallback: default focus_next
|
|
106
|
+
self.screen.focus_next()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Screen 1: Basics
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
class BasicsScreen(Screen):
|
|
114
|
+
"""Collect decision-pack name and description."""
|
|
115
|
+
|
|
116
|
+
CSS = """
|
|
117
|
+
BasicsScreen {
|
|
118
|
+
align: left bottom;
|
|
119
|
+
}
|
|
120
|
+
#basics-container {
|
|
121
|
+
width: 66%;
|
|
122
|
+
height: auto;
|
|
123
|
+
padding: 1 2;
|
|
124
|
+
}
|
|
125
|
+
.field-label {
|
|
126
|
+
margin-top: 1;
|
|
127
|
+
color: $text;
|
|
128
|
+
}
|
|
129
|
+
.field-hint {
|
|
130
|
+
color: $text-muted;
|
|
131
|
+
text-style: italic;
|
|
132
|
+
margin-bottom: 0;
|
|
133
|
+
text-wrap: wrap;
|
|
134
|
+
width: 1fr;
|
|
135
|
+
}
|
|
136
|
+
.error-label {
|
|
137
|
+
color: $error;
|
|
138
|
+
}
|
|
139
|
+
.nav-bar {
|
|
140
|
+
margin-top: 1;
|
|
141
|
+
height: 1;
|
|
142
|
+
align: right middle;
|
|
143
|
+
}
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def compose(self) -> ComposeResult:
|
|
147
|
+
with Vertical(id="basics-container"):
|
|
148
|
+
yield Label("[b]Step 1 of 8[/b] — Basics", classes="field-label")
|
|
149
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
150
|
+
yield Label("decision-pack name", classes="field-label")
|
|
151
|
+
yield Label("Alphanumeric, hyphens, underscores", classes="field-hint")
|
|
152
|
+
yield Input(id="name-input", placeholder="my-dpack")
|
|
153
|
+
with Horizontal(id="collision-bar"):
|
|
154
|
+
yield Label("", id="name-error", classes="error-label")
|
|
155
|
+
yield Button("Delete & Overwrite", id="overwrite-btn", variant="error")
|
|
156
|
+
|
|
157
|
+
yield Label("Description", classes="field-label")
|
|
158
|
+
yield Input(id="desc-input", placeholder="(default: dlab decision-pack: {name})")
|
|
159
|
+
|
|
160
|
+
yield Label("CLI name", classes="field-label")
|
|
161
|
+
yield Label("Override command name when installed (default: decision-pack name)", classes="field-hint")
|
|
162
|
+
yield Input(id="cli-name-input", placeholder="(same as decision-pack name)")
|
|
163
|
+
|
|
164
|
+
with Horizontal(classes="nav-bar"):
|
|
165
|
+
yield Button("Next →", id="next-btn")
|
|
166
|
+
|
|
167
|
+
def on_mount(self) -> None:
|
|
168
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
169
|
+
if state.get("name"):
|
|
170
|
+
self.query_one("#name-input", Input).value = state["name"]
|
|
171
|
+
if state.get("description"):
|
|
172
|
+
self.query_one("#desc-input", Input).value = state["description"]
|
|
173
|
+
if state.get("cli_name"):
|
|
174
|
+
self.query_one("#cli-name-input", Input).value = state["cli_name"]
|
|
175
|
+
self.query_one("#overwrite-btn", Button).display = False
|
|
176
|
+
self.query_one("#name-input", Input).focus()
|
|
177
|
+
|
|
178
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
179
|
+
if event.input.id == "name-input":
|
|
180
|
+
self.query_one("#overwrite-btn", Button).display = False
|
|
181
|
+
self.app.wizard_state.pop("overwrite_existing", None)
|
|
182
|
+
self.query_one("#name-error", Label).update("")
|
|
183
|
+
|
|
184
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
185
|
+
if event.button.id == "overwrite-btn":
|
|
186
|
+
self.app.wizard_state["overwrite_existing"] = True
|
|
187
|
+
self.query_one("#name-error", Label).update("[green]decision-pack will be overwritten[/green]")
|
|
188
|
+
self.query_one("#overwrite-btn", Button).display = False
|
|
189
|
+
return
|
|
190
|
+
|
|
191
|
+
if event.button.id != "next-btn":
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
name: str = self.query_one("#name-input", Input).value.strip()
|
|
195
|
+
error: str | None = validate_dpack_name(name)
|
|
196
|
+
error_label: Label = self.query_one("#name-error", Label)
|
|
197
|
+
if error:
|
|
198
|
+
error_label.update(f"[red]{error}[/red]")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# Check for existing decision-pack
|
|
202
|
+
dpack_dir: Path = Path(self.app.output_dir) / name
|
|
203
|
+
if dpack_dir.exists() and not self.app.wizard_state.get("overwrite_existing"):
|
|
204
|
+
error_label.update("[red]decision-pack already exists.[/red]")
|
|
205
|
+
self.query_one("#overwrite-btn", Button).display = True
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
error_label.update("")
|
|
209
|
+
|
|
210
|
+
desc: str = self.query_one("#desc-input", Input).value.strip()
|
|
211
|
+
|
|
212
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
213
|
+
state["name"] = name
|
|
214
|
+
state["description"] = desc or f"dlab decision-pack: {name}"
|
|
215
|
+
state["cli_name"] = self.query_one("#cli-name-input", Input).value.strip()
|
|
216
|
+
|
|
217
|
+
self.app.push_screen(ContainerScreen())
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Screen 2: Container setup
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
class ContainerScreen(Screen):
|
|
225
|
+
"""Choose package manager and base image."""
|
|
226
|
+
|
|
227
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
228
|
+
|
|
229
|
+
CSS = """
|
|
230
|
+
ContainerScreen {
|
|
231
|
+
align: left bottom;
|
|
232
|
+
}
|
|
233
|
+
#container-container {
|
|
234
|
+
width: 66%;
|
|
235
|
+
height: auto;
|
|
236
|
+
max-height: 80%;
|
|
237
|
+
padding: 1 2;
|
|
238
|
+
}
|
|
239
|
+
.field-label {
|
|
240
|
+
margin-top: 1;
|
|
241
|
+
}
|
|
242
|
+
.field-hint {
|
|
243
|
+
color: $text-muted;
|
|
244
|
+
text-style: italic;
|
|
245
|
+
text-wrap: wrap;
|
|
246
|
+
width: 1fr;
|
|
247
|
+
}
|
|
248
|
+
.cb-desc {
|
|
249
|
+
color: $text-muted;
|
|
250
|
+
text-style: italic;
|
|
251
|
+
padding-left: 4;
|
|
252
|
+
margin-bottom: 0;
|
|
253
|
+
text-wrap: wrap;
|
|
254
|
+
width: 1fr;
|
|
255
|
+
}
|
|
256
|
+
.nav-bar {
|
|
257
|
+
margin-top: 1;
|
|
258
|
+
height: 1;
|
|
259
|
+
align: right middle;
|
|
260
|
+
}
|
|
261
|
+
#base-image-list {
|
|
262
|
+
height: auto;
|
|
263
|
+
max-height: 8;
|
|
264
|
+
margin-top: 1;
|
|
265
|
+
scrollbar-size: 1 1;
|
|
266
|
+
}
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
PKG_MGR_OPTIONS: list[tuple[str, str, str]] = [
|
|
270
|
+
("conda", "conda (Recommended)",
|
|
271
|
+
"Miniconda base image. Creates environment.yml with conda-forge channel. "
|
|
272
|
+
"Best for scientific Python (BLAS, PyMC, JAX, etc.)"),
|
|
273
|
+
("pip", "pip",
|
|
274
|
+
"Python slim base image. Creates requirements.txt. "
|
|
275
|
+
"Simplest option for most projects."),
|
|
276
|
+
("uv", "uv",
|
|
277
|
+
"Python slim base image with uv. Creates requirements.txt. "
|
|
278
|
+
"Fast dependency resolution, drop-in pip replacement."),
|
|
279
|
+
("pixi", "pixi",
|
|
280
|
+
"Debian base with pixi. Creates pixi.toml. "
|
|
281
|
+
"Modern conda-forge workflow with lockfile support."),
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
def __init__(self) -> None:
|
|
285
|
+
super().__init__()
|
|
286
|
+
self._programmatic_fill: bool = False
|
|
287
|
+
|
|
288
|
+
def compose(self) -> ComposeResult:
|
|
289
|
+
with FormScroll(id="container-container"):
|
|
290
|
+
yield Label("[b]Step 2 of 8[/b] — Container Setup", classes="field-label")
|
|
291
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
292
|
+
|
|
293
|
+
yield Label("[b]Package Manager[/b]", classes="field-label")
|
|
294
|
+
with Vertical(classes="cb-group"):
|
|
295
|
+
for key, label, desc in self.PKG_MGR_OPTIONS:
|
|
296
|
+
yield DpackCheckbox(
|
|
297
|
+
label,
|
|
298
|
+
value=(key == "conda"),
|
|
299
|
+
id=f"pkg-{key}",
|
|
300
|
+
)
|
|
301
|
+
yield Label(desc, classes="cb-desc")
|
|
302
|
+
|
|
303
|
+
yield Label("[b]Base Image[/b]", classes="field-label")
|
|
304
|
+
yield Label("Select or enter a custom Docker base image", classes="field-hint")
|
|
305
|
+
yield Input(id="base-image-input", placeholder="python:3.11-slim")
|
|
306
|
+
with Vertical(classes="selection-group"):
|
|
307
|
+
yield OptionList(
|
|
308
|
+
*[Option(img, id=img) for img in KNOWN_BASE_IMAGES],
|
|
309
|
+
id="base-image-list",
|
|
310
|
+
)
|
|
311
|
+
yield Label("Tab to continue", classes="option-hint")
|
|
312
|
+
|
|
313
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
314
|
+
yield Button("Next →", id="next-btn")
|
|
315
|
+
yield Button("← Back", id="back-btn")
|
|
316
|
+
|
|
317
|
+
def on_mount(self) -> None:
|
|
318
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
319
|
+
# Restore package manager selection
|
|
320
|
+
pkg_mgr: str = state.get("package_manager", "conda")
|
|
321
|
+
for key, _, _ in self.PKG_MGR_OPTIONS:
|
|
322
|
+
self.query_one(f"#pkg-{key}", Checkbox).value = (key == pkg_mgr)
|
|
323
|
+
|
|
324
|
+
# Set base image from state or default for package manager
|
|
325
|
+
base_image: str = state.get("base_image", PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim"))
|
|
326
|
+
self._programmatic_fill = True
|
|
327
|
+
self.query_one("#base-image-input", Input).value = base_image
|
|
328
|
+
|
|
329
|
+
self.query_one(f"#pkg-{pkg_mgr}", Checkbox).focus()
|
|
330
|
+
|
|
331
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
332
|
+
# Radio group for package manager
|
|
333
|
+
pkg_ids: list[str] = [f"pkg-{k}" for k, _, _ in self.PKG_MGR_OPTIONS]
|
|
334
|
+
if event.checkbox.id in pkg_ids and event.value:
|
|
335
|
+
for pid in pkg_ids:
|
|
336
|
+
if pid != event.checkbox.id:
|
|
337
|
+
self.query_one(f"#{pid}", Checkbox).value = False
|
|
338
|
+
# Update base image to default for selected package manager
|
|
339
|
+
pkg_mgr: str = event.checkbox.id.removeprefix("pkg-")
|
|
340
|
+
default_image: str = PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim")
|
|
341
|
+
self._programmatic_fill = True
|
|
342
|
+
self.query_one("#base-image-input", Input).value = default_image
|
|
343
|
+
|
|
344
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
345
|
+
if event.option_list.id == "base-image-list":
|
|
346
|
+
self._programmatic_fill = True
|
|
347
|
+
self.query_one("#base-image-input", Input).value = str(event.option.id)
|
|
348
|
+
self.screen.focus_next()
|
|
349
|
+
|
|
350
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
351
|
+
if event.input.id != "base-image-input":
|
|
352
|
+
return
|
|
353
|
+
if self._programmatic_fill:
|
|
354
|
+
self._programmatic_fill = False
|
|
355
|
+
return
|
|
356
|
+
# Filter base image list
|
|
357
|
+
query: str = event.value.strip().lower()
|
|
358
|
+
ol: OptionList = self.query_one("#base-image-list", OptionList)
|
|
359
|
+
ol.clear_options()
|
|
360
|
+
for img in KNOWN_BASE_IMAGES:
|
|
361
|
+
if not query or query in img.lower():
|
|
362
|
+
ol.add_option(Option(img, id=img))
|
|
363
|
+
|
|
364
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
365
|
+
if event.button.id == "back-btn":
|
|
366
|
+
self.app.pop_screen()
|
|
367
|
+
return
|
|
368
|
+
if event.button.id != "next-btn":
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
372
|
+
|
|
373
|
+
# Get selected package manager from radio checkboxes
|
|
374
|
+
pkg_mgr: str = "conda"
|
|
375
|
+
for key, _, _ in self.PKG_MGR_OPTIONS:
|
|
376
|
+
if self.query_one(f"#pkg-{key}", Checkbox).value:
|
|
377
|
+
pkg_mgr = key
|
|
378
|
+
break
|
|
379
|
+
|
|
380
|
+
state["package_manager"] = pkg_mgr
|
|
381
|
+
|
|
382
|
+
# Get base image (from input, not from package manager default)
|
|
383
|
+
base_image: str = self.query_one("#base-image-input", Input).value.strip()
|
|
384
|
+
if not base_image:
|
|
385
|
+
base_image = PACKAGE_MANAGER_BASE_IMAGES.get(pkg_mgr, "python:3.11-slim")
|
|
386
|
+
state["base_image"] = base_image
|
|
387
|
+
|
|
388
|
+
self.app.push_screen(FeaturesScreen())
|
|
389
|
+
|
|
390
|
+
def action_go_back(self) -> None:
|
|
391
|
+
self.app.pop_screen()
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
# ---------------------------------------------------------------------------
|
|
395
|
+
# Screen 3: Additional features
|
|
396
|
+
# ---------------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
class FeaturesScreen(Screen):
|
|
399
|
+
"""Configure additional features: dhub, python lib, modal, requires_data."""
|
|
400
|
+
|
|
401
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
402
|
+
|
|
403
|
+
CSS = """
|
|
404
|
+
FeaturesScreen {
|
|
405
|
+
align: left bottom;
|
|
406
|
+
}
|
|
407
|
+
#features-container {
|
|
408
|
+
width: 66%;
|
|
409
|
+
height: auto;
|
|
410
|
+
padding: 1 2;
|
|
411
|
+
}
|
|
412
|
+
.field-label {
|
|
413
|
+
margin-top: 1;
|
|
414
|
+
}
|
|
415
|
+
.field-hint {
|
|
416
|
+
color: $text-muted;
|
|
417
|
+
text-style: italic;
|
|
418
|
+
text-wrap: wrap;
|
|
419
|
+
width: 1fr;
|
|
420
|
+
}
|
|
421
|
+
.cb-desc {
|
|
422
|
+
color: $text-muted;
|
|
423
|
+
text-style: italic;
|
|
424
|
+
padding-left: 4;
|
|
425
|
+
margin-bottom: 0;
|
|
426
|
+
text-wrap: wrap;
|
|
427
|
+
width: 1fr;
|
|
428
|
+
}
|
|
429
|
+
.nav-bar {
|
|
430
|
+
margin-top: 1;
|
|
431
|
+
height: 1;
|
|
432
|
+
align: right middle;
|
|
433
|
+
}
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
def compose(self) -> ComposeResult:
|
|
437
|
+
with Vertical(id="features-container"):
|
|
438
|
+
yield Label("[b]Step 3 of 8[/b] — Additional Features", classes="field-label")
|
|
439
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
440
|
+
|
|
441
|
+
with Vertical(classes="cb-group"):
|
|
442
|
+
yield DpackCheckbox(
|
|
443
|
+
"Decision Hub integration",
|
|
444
|
+
value=True,
|
|
445
|
+
id="dhub-cb",
|
|
446
|
+
)
|
|
447
|
+
yield Label(
|
|
448
|
+
"The agent can dynamically search hub.decision.ai for skills "
|
|
449
|
+
"and install them at runtime. Adds dhub-cli to container dependencies.",
|
|
450
|
+
classes="cb-desc",
|
|
451
|
+
)
|
|
452
|
+
yield DpackCheckbox(
|
|
453
|
+
"Python library — create a {name}_lib/ package in docker/",
|
|
454
|
+
value=False,
|
|
455
|
+
id="python-lib-cb",
|
|
456
|
+
)
|
|
457
|
+
yield Label(
|
|
458
|
+
"Adds {name}_lib/__init__.py, COPY + PYTHONPATH in Dockerfile.",
|
|
459
|
+
classes="cb-desc",
|
|
460
|
+
)
|
|
461
|
+
yield DpackCheckbox(
|
|
462
|
+
"Modal integration — create docker/modal_app/ with example script",
|
|
463
|
+
value=False,
|
|
464
|
+
id="modal-cb",
|
|
465
|
+
)
|
|
466
|
+
yield Label(
|
|
467
|
+
"For serverless cloud execution (e.g. heavy compute).",
|
|
468
|
+
classes="cb-desc",
|
|
469
|
+
)
|
|
470
|
+
yield DpackCheckbox(
|
|
471
|
+
"Requires data directory — agent needs --data to run",
|
|
472
|
+
value=True,
|
|
473
|
+
id="requires-data-cb",
|
|
474
|
+
)
|
|
475
|
+
yield Label(
|
|
476
|
+
"If unchecked, --data becomes optional when running this decision-pack.",
|
|
477
|
+
classes="cb-desc",
|
|
478
|
+
)
|
|
479
|
+
yield DpackCheckbox(
|
|
480
|
+
"Requires prompt — agent needs --prompt or --prompt-file to run",
|
|
481
|
+
value=True,
|
|
482
|
+
id="requires-prompt-cb",
|
|
483
|
+
)
|
|
484
|
+
yield Label(
|
|
485
|
+
"If unchecked, --prompt becomes optional (useful for fully automated decision-packs).",
|
|
486
|
+
classes="cb-desc",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
490
|
+
yield Button("Next →", id="next-btn")
|
|
491
|
+
yield Button("← Back", id="back-btn")
|
|
492
|
+
|
|
493
|
+
def on_mount(self) -> None:
|
|
494
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
495
|
+
if state.get("dhub_integration") is not None:
|
|
496
|
+
self.query_one("#dhub-cb", Checkbox).value = state["dhub_integration"]
|
|
497
|
+
if state.get("python_lib") is not None:
|
|
498
|
+
self.query_one("#python-lib-cb", Checkbox).value = state["python_lib"]
|
|
499
|
+
if state.get("modal_integration") is not None:
|
|
500
|
+
self.query_one("#modal-cb", Checkbox).value = state["modal_integration"]
|
|
501
|
+
if state.get("requires_data") is not None:
|
|
502
|
+
self.query_one("#requires-data-cb", Checkbox).value = state["requires_data"]
|
|
503
|
+
if state.get("requires_prompt") is not None:
|
|
504
|
+
self.query_one("#requires-prompt-cb", Checkbox).value = state["requires_prompt"]
|
|
505
|
+
self.query_one("#dhub-cb", Checkbox).focus()
|
|
506
|
+
|
|
507
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
508
|
+
if event.button.id == "back-btn":
|
|
509
|
+
self.app.pop_screen()
|
|
510
|
+
return
|
|
511
|
+
if event.button.id != "next-btn":
|
|
512
|
+
return
|
|
513
|
+
|
|
514
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
515
|
+
name: str = state.get("name", "dpack")
|
|
516
|
+
|
|
517
|
+
state["dhub_integration"] = self.query_one("#dhub-cb", Checkbox).value
|
|
518
|
+
|
|
519
|
+
python_lib: bool = self.query_one("#python-lib-cb", Checkbox).value
|
|
520
|
+
state["python_lib"] = python_lib
|
|
521
|
+
state["python_lib_name"] = name.replace("-", "_") + "_lib" if python_lib else ""
|
|
522
|
+
|
|
523
|
+
state["modal_integration"] = self.query_one("#modal-cb", Checkbox).value
|
|
524
|
+
state["requires_data"] = self.query_one("#requires-data-cb", Checkbox).value
|
|
525
|
+
state["requires_prompt"] = self.query_one("#requires-prompt-cb", Checkbox).value
|
|
526
|
+
|
|
527
|
+
self.app.push_screen(ModelScreen())
|
|
528
|
+
|
|
529
|
+
def action_go_back(self) -> None:
|
|
530
|
+
self.app.pop_screen()
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------------------
|
|
534
|
+
# Screen 3: Model selection
|
|
535
|
+
# ---------------------------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
class ModelScreen(Screen):
|
|
538
|
+
"""Select default model with live filtering."""
|
|
539
|
+
|
|
540
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
541
|
+
|
|
542
|
+
CSS = """
|
|
543
|
+
ModelScreen {
|
|
544
|
+
align: left bottom;
|
|
545
|
+
}
|
|
546
|
+
#model-container {
|
|
547
|
+
width: 66%;
|
|
548
|
+
height: auto;
|
|
549
|
+
padding: 1 2;
|
|
550
|
+
}
|
|
551
|
+
#model-results {
|
|
552
|
+
height: auto;
|
|
553
|
+
max-height: 18;
|
|
554
|
+
}
|
|
555
|
+
.nav-bar {
|
|
556
|
+
margin-top: 1;
|
|
557
|
+
height: 1;
|
|
558
|
+
align: right middle;
|
|
559
|
+
}
|
|
560
|
+
.field-label {
|
|
561
|
+
margin-top: 1;
|
|
562
|
+
}
|
|
563
|
+
.field-hint {
|
|
564
|
+
color: $text-muted;
|
|
565
|
+
text-style: italic;
|
|
566
|
+
text-wrap: wrap;
|
|
567
|
+
width: 1fr;
|
|
568
|
+
}
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
def __init__(self) -> None:
|
|
572
|
+
super().__init__()
|
|
573
|
+
self._models: list[str] = get_model_list()
|
|
574
|
+
self._programmatic_fill: bool = False
|
|
575
|
+
|
|
576
|
+
def compose(self) -> ComposeResult:
|
|
577
|
+
with Vertical(id="model-container"):
|
|
578
|
+
yield Label("[b]Step 4 of 8[/b] — Default Model", classes="field-label")
|
|
579
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
580
|
+
yield Label("Type to filter, or enter a custom model name", classes="field-hint")
|
|
581
|
+
yield Input(id="model-input", placeholder="opencode/big-pickle")
|
|
582
|
+
with Vertical(classes="selection-group"):
|
|
583
|
+
yield OptionList(
|
|
584
|
+
*[Option(m, id=m) for m in self._models],
|
|
585
|
+
id="model-results",
|
|
586
|
+
)
|
|
587
|
+
yield Label("Tab to continue", classes="option-hint")
|
|
588
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
589
|
+
yield Button("Next →", id="next-btn")
|
|
590
|
+
yield Button("← Back", id="back-btn")
|
|
591
|
+
|
|
592
|
+
def on_mount(self) -> None:
|
|
593
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
594
|
+
if state.get("default_model"):
|
|
595
|
+
self._programmatic_fill = True
|
|
596
|
+
self.query_one("#model-input", Input).value = state["default_model"]
|
|
597
|
+
self.query_one("#model-input", Input).focus()
|
|
598
|
+
self._refresh_models()
|
|
599
|
+
|
|
600
|
+
@work(thread=True)
|
|
601
|
+
def _refresh_models(self) -> None:
|
|
602
|
+
"""Fetch models from API in background and refresh the list."""
|
|
603
|
+
from dlab.create_dpack import fetch_models_from_api, save_model_cache
|
|
604
|
+
try:
|
|
605
|
+
data: dict[str, Any] = fetch_models_from_api()
|
|
606
|
+
save_model_cache(data)
|
|
607
|
+
from dlab.create_dpack import _model_sort_key
|
|
608
|
+
new_models: list[str] = sorted(set(self._models) | set(data.get("models", [])), key=_model_sort_key)
|
|
609
|
+
if new_models != self._models:
|
|
610
|
+
self._models = new_models
|
|
611
|
+
self.app.call_from_thread(self._rebuild_options, "")
|
|
612
|
+
except Exception:
|
|
613
|
+
pass # Network failure is fine — we have cached/hardcoded models
|
|
614
|
+
|
|
615
|
+
def _rebuild_options(self, query: str) -> None:
|
|
616
|
+
"""Rebuild the OptionList with current model list, filtered by query."""
|
|
617
|
+
matches: list[str] = filter_models(query, self._models) if query else self._models
|
|
618
|
+
ol: OptionList = self.query_one("#model-results", OptionList)
|
|
619
|
+
ol.clear_options()
|
|
620
|
+
for m in matches:
|
|
621
|
+
ol.add_option(Option(m, id=m))
|
|
622
|
+
|
|
623
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
624
|
+
if event.input.id != "model-input":
|
|
625
|
+
return
|
|
626
|
+
if self._programmatic_fill:
|
|
627
|
+
self._programmatic_fill = False
|
|
628
|
+
return
|
|
629
|
+
self._rebuild_options(event.value.strip())
|
|
630
|
+
|
|
631
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
632
|
+
if event.option_list.id != "model-results":
|
|
633
|
+
return
|
|
634
|
+
self._programmatic_fill = True
|
|
635
|
+
self.query_one("#model-input", Input).value = str(event.option.prompt)
|
|
636
|
+
self.query_one("#next-btn", Button).focus()
|
|
637
|
+
|
|
638
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
639
|
+
if event.button.id == "back-btn":
|
|
640
|
+
self.app.pop_screen()
|
|
641
|
+
return
|
|
642
|
+
if event.button.id != "next-btn":
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
model: str = self.query_one("#model-input", Input).value.strip()
|
|
646
|
+
if not model:
|
|
647
|
+
model = "opencode/big-pickle"
|
|
648
|
+
|
|
649
|
+
self.app.wizard_state["default_model"] = model
|
|
650
|
+
self.app.push_screen(PermissionsScreen())
|
|
651
|
+
|
|
652
|
+
def action_go_back(self) -> None:
|
|
653
|
+
self.app.pop_screen()
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Screen 4: Permissions
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
class PermissionsScreen(Screen):
|
|
661
|
+
"""Configure opencode.json permissions."""
|
|
662
|
+
|
|
663
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
664
|
+
|
|
665
|
+
CSS = """
|
|
666
|
+
PermissionsScreen {
|
|
667
|
+
align: left bottom;
|
|
668
|
+
}
|
|
669
|
+
#perms-container {
|
|
670
|
+
width: 66%;
|
|
671
|
+
height: auto;
|
|
672
|
+
max-height: 80%;
|
|
673
|
+
padding: 1 2;
|
|
674
|
+
}
|
|
675
|
+
.field-label {
|
|
676
|
+
margin-top: 1;
|
|
677
|
+
}
|
|
678
|
+
.field-hint {
|
|
679
|
+
color: $text-muted;
|
|
680
|
+
text-style: italic;
|
|
681
|
+
text-wrap: wrap;
|
|
682
|
+
width: 1fr;
|
|
683
|
+
}
|
|
684
|
+
.cb-desc {
|
|
685
|
+
color: $text-muted;
|
|
686
|
+
text-style: italic;
|
|
687
|
+
padding-left: 4;
|
|
688
|
+
margin-bottom: 0;
|
|
689
|
+
text-wrap: wrap;
|
|
690
|
+
width: 1fr;
|
|
691
|
+
}
|
|
692
|
+
.section-divider {
|
|
693
|
+
margin-top: 1;
|
|
694
|
+
color: $accent;
|
|
695
|
+
}
|
|
696
|
+
.cb-group {
|
|
697
|
+
height: auto;
|
|
698
|
+
}
|
|
699
|
+
.nav-bar {
|
|
700
|
+
margin-top: 1;
|
|
701
|
+
height: 1;
|
|
702
|
+
align: right middle;
|
|
703
|
+
}
|
|
704
|
+
"""
|
|
705
|
+
|
|
706
|
+
def compose(self) -> ComposeResult:
|
|
707
|
+
with FormScroll(id="perms-container"):
|
|
708
|
+
yield Label("[b]Step 5 of 8[/b] — Permissions", classes="field-label")
|
|
709
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
710
|
+
yield Label(
|
|
711
|
+
"These permissions are written to opencode.json and apply project-wide. "
|
|
712
|
+
"In automated mode (opencode run), every permission must be explicitly "
|
|
713
|
+
"allow or deny — there is no interactive approval prompt.",
|
|
714
|
+
classes="field-hint",
|
|
715
|
+
)
|
|
716
|
+
yield Label(
|
|
717
|
+
"Each agent's .md frontmatter has a tools: section that can further "
|
|
718
|
+
"restrict permissions per-agent (overrides opencode.json). "
|
|
719
|
+
"Subagent permissions are controlled in their own .md frontmatter.",
|
|
720
|
+
classes="field-hint",
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
yield Label("[b]High-impact[/b]", classes="section-divider")
|
|
724
|
+
with Vertical(classes="cb-group"):
|
|
725
|
+
for key, label, desc, default in CONFIGURABLE_PERMISSIONS[:HIGH_IMPACT_PERMISSION_COUNT]:
|
|
726
|
+
yield DpackCheckbox(label, value=(default == "allow"), id=f"perm-{key}")
|
|
727
|
+
yield Label(desc, classes="cb-desc")
|
|
728
|
+
|
|
729
|
+
yield Label("[b]Internal/basic[/b]", classes="section-divider")
|
|
730
|
+
with Vertical(classes="cb-group"):
|
|
731
|
+
for key, label, desc, default in CONFIGURABLE_PERMISSIONS[HIGH_IMPACT_PERMISSION_COUNT:]:
|
|
732
|
+
yield DpackCheckbox(label, value=(default == "allow"), id=f"perm-{key}")
|
|
733
|
+
yield Label(desc, classes="cb-desc")
|
|
734
|
+
|
|
735
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
736
|
+
yield Button("Next →", id="next-btn")
|
|
737
|
+
yield Button("← Back", id="back-btn")
|
|
738
|
+
|
|
739
|
+
def on_mount(self) -> None:
|
|
740
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
741
|
+
perms: dict[str, str] = state.get("permissions", {})
|
|
742
|
+
|
|
743
|
+
for key, _label, _desc, _default in CONFIGURABLE_PERMISSIONS:
|
|
744
|
+
if key in perms:
|
|
745
|
+
self.query_one(f"#perm-{key}", Checkbox).value = (perms[key] == "allow")
|
|
746
|
+
|
|
747
|
+
first_key: str = CONFIGURABLE_PERMISSIONS[0][0]
|
|
748
|
+
self.query_one(f"#perm-{first_key}", Checkbox).focus()
|
|
749
|
+
|
|
750
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
751
|
+
if event.button.id == "back-btn":
|
|
752
|
+
self.app.pop_screen()
|
|
753
|
+
return
|
|
754
|
+
if event.button.id != "next-btn":
|
|
755
|
+
return
|
|
756
|
+
|
|
757
|
+
perms: dict[str, str] = {}
|
|
758
|
+
for key, _label, _desc, _default in CONFIGURABLE_PERMISSIONS:
|
|
759
|
+
checked: bool = self.query_one(f"#perm-{key}", Checkbox).value
|
|
760
|
+
perms[key] = "allow" if checked else "deny"
|
|
761
|
+
self.app.wizard_state["permissions"] = perms
|
|
762
|
+
|
|
763
|
+
self.app.push_screen(SkeletonsScreen())
|
|
764
|
+
|
|
765
|
+
def action_go_back(self) -> None:
|
|
766
|
+
self.app.pop_screen()
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ---------------------------------------------------------------------------
|
|
770
|
+
# Screen 5: Directory skeletons
|
|
771
|
+
# ---------------------------------------------------------------------------
|
|
772
|
+
|
|
773
|
+
class SkeletonsScreen(Screen):
|
|
774
|
+
"""Select which opencode directory skeletons to scaffold."""
|
|
775
|
+
|
|
776
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
777
|
+
|
|
778
|
+
CSS = """
|
|
779
|
+
SkeletonsScreen {
|
|
780
|
+
align: left bottom;
|
|
781
|
+
}
|
|
782
|
+
#skeletons-container {
|
|
783
|
+
width: 66%;
|
|
784
|
+
height: auto;
|
|
785
|
+
max-height: 80%;
|
|
786
|
+
padding: 1 2;
|
|
787
|
+
}
|
|
788
|
+
.field-label {
|
|
789
|
+
margin-top: 1;
|
|
790
|
+
}
|
|
791
|
+
.field-hint {
|
|
792
|
+
color: $text-muted;
|
|
793
|
+
text-style: italic;
|
|
794
|
+
text-wrap: wrap;
|
|
795
|
+
width: 1fr;
|
|
796
|
+
}
|
|
797
|
+
.cb-desc {
|
|
798
|
+
color: $text-muted;
|
|
799
|
+
text-style: italic;
|
|
800
|
+
padding-left: 4;
|
|
801
|
+
margin-bottom: 0;
|
|
802
|
+
text-wrap: wrap;
|
|
803
|
+
width: 1fr;
|
|
804
|
+
}
|
|
805
|
+
.nav-bar {
|
|
806
|
+
margin-top: 1;
|
|
807
|
+
height: 1;
|
|
808
|
+
align: right middle;
|
|
809
|
+
}
|
|
810
|
+
"""
|
|
811
|
+
|
|
812
|
+
def compose(self) -> ComposeResult:
|
|
813
|
+
with FormScroll(id="skeletons-container"):
|
|
814
|
+
yield Label("[b]Step 6 of 8[/b] — Directory Skeletons", classes="field-label")
|
|
815
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
816
|
+
yield Label(
|
|
817
|
+
"Scaffold opencode directories with example files. "
|
|
818
|
+
"All are enabled by default — disable the ones you don't need.",
|
|
819
|
+
classes="field-hint",
|
|
820
|
+
)
|
|
821
|
+
|
|
822
|
+
with Vertical(classes="cb-group"):
|
|
823
|
+
yield DpackCheckbox(
|
|
824
|
+
"Skills — knowledge files the agent can reference (opencode/skills/)",
|
|
825
|
+
value=True,
|
|
826
|
+
id="skel-skills",
|
|
827
|
+
)
|
|
828
|
+
yield Label(
|
|
829
|
+
"Add domain-specific knowledge, API references, or best practices.",
|
|
830
|
+
classes="cb-desc",
|
|
831
|
+
)
|
|
832
|
+
yield DpackCheckbox(
|
|
833
|
+
"Tools — custom TypeScript tools (opencode/tools/)",
|
|
834
|
+
value=True,
|
|
835
|
+
id="skel-tools",
|
|
836
|
+
)
|
|
837
|
+
yield Label(
|
|
838
|
+
"Extend the agent with custom tools written in TypeScript.",
|
|
839
|
+
classes="cb-desc",
|
|
840
|
+
id="skel-tools-desc",
|
|
841
|
+
)
|
|
842
|
+
yield DpackCheckbox(
|
|
843
|
+
"Subagents — additional agents the main agent can delegate to",
|
|
844
|
+
value=True,
|
|
845
|
+
id="skel-subagents",
|
|
846
|
+
)
|
|
847
|
+
yield Label(
|
|
848
|
+
"Create an example subagent .md file in opencode/agents/.",
|
|
849
|
+
classes="cb-desc",
|
|
850
|
+
)
|
|
851
|
+
yield DpackCheckbox(
|
|
852
|
+
"Parallel subagents — run multiple subagent instances simultaneously",
|
|
853
|
+
value=True,
|
|
854
|
+
id="skel-parallel",
|
|
855
|
+
)
|
|
856
|
+
yield Label(
|
|
857
|
+
"Adds parallel agent YAML config + parallel-agents tool to the main agent. "
|
|
858
|
+
"Requires subagents (auto-enabled).",
|
|
859
|
+
classes="cb-desc",
|
|
860
|
+
)
|
|
861
|
+
|
|
862
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
863
|
+
yield Button("Next →", id="next-btn")
|
|
864
|
+
yield Button("← Back", id="back-btn")
|
|
865
|
+
|
|
866
|
+
def on_mount(self) -> None:
|
|
867
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
868
|
+
skels: dict[str, bool] = state.get("skeletons", {})
|
|
869
|
+
|
|
870
|
+
# Restore from state if going back (skels may have False values)
|
|
871
|
+
if skels:
|
|
872
|
+
self.query_one("#skel-skills", Checkbox).value = skels.get("skills", True)
|
|
873
|
+
self.query_one("#skel-tools", Checkbox).value = skels.get("tools", True)
|
|
874
|
+
self.query_one("#skel-subagents", Checkbox).value = skels.get("subagents", True)
|
|
875
|
+
self.query_one("#skel-parallel", Checkbox).value = skels.get("parallel_agents", True)
|
|
876
|
+
|
|
877
|
+
# Modal integration requires tools
|
|
878
|
+
if state.get("modal_integration"):
|
|
879
|
+
tools_cb: Checkbox = self.query_one("#skel-tools", Checkbox)
|
|
880
|
+
tools_cb.value = True
|
|
881
|
+
tools_cb.disabled = True
|
|
882
|
+
self.query_one("#skel-tools-desc", Label).update(
|
|
883
|
+
"Required by Modal integration (includes run-on-modal tool)."
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
# Decision Hub integration requires skills
|
|
887
|
+
if state.get("dhub_integration"):
|
|
888
|
+
skills_cb: Checkbox = self.query_one("#skel-skills", Checkbox)
|
|
889
|
+
skills_cb.value = True
|
|
890
|
+
skills_cb.disabled = True
|
|
891
|
+
|
|
892
|
+
self.query_one("#skel-skills", Checkbox).focus()
|
|
893
|
+
|
|
894
|
+
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
|
895
|
+
# parallel implies subagents
|
|
896
|
+
if event.checkbox.id == "skel-parallel" and event.value:
|
|
897
|
+
self.query_one("#skel-subagents", Checkbox).value = True
|
|
898
|
+
|
|
899
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
900
|
+
if event.button.id == "back-btn":
|
|
901
|
+
self.app.pop_screen()
|
|
902
|
+
return
|
|
903
|
+
if event.button.id != "next-btn":
|
|
904
|
+
return
|
|
905
|
+
|
|
906
|
+
self.app.wizard_state["skeletons"] = {
|
|
907
|
+
"skills": self.query_one("#skel-skills", Checkbox).value,
|
|
908
|
+
"tools": self.query_one("#skel-tools", Checkbox).value,
|
|
909
|
+
"subagents": self.query_one("#skel-subagents", Checkbox).value,
|
|
910
|
+
"parallel_agents": self.query_one("#skel-parallel", Checkbox).value,
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
# Skip skill search if skills not selected
|
|
914
|
+
if self.app.wizard_state["skeletons"]["skills"]:
|
|
915
|
+
self.app.push_screen(SkillSearchScreen())
|
|
916
|
+
else:
|
|
917
|
+
self.app.push_screen(SummaryScreen())
|
|
918
|
+
|
|
919
|
+
def action_go_back(self) -> None:
|
|
920
|
+
self.app.pop_screen()
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# ---------------------------------------------------------------------------
|
|
924
|
+
# Screen 6: Skill search (Decision Hub)
|
|
925
|
+
# ---------------------------------------------------------------------------
|
|
926
|
+
|
|
927
|
+
class SkillSearchScreen(Screen):
|
|
928
|
+
"""Search and select skills from Decision Hub.
|
|
929
|
+
|
|
930
|
+
Two search mechanisms feed into the same results list:
|
|
931
|
+
- Keyword search (Enter in the input) — via /v1/skills
|
|
932
|
+
- Natural-language "ask" (automatic on mount from description) — via /v1/ask
|
|
933
|
+
"""
|
|
934
|
+
|
|
935
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
936
|
+
|
|
937
|
+
CSS = """
|
|
938
|
+
SkillSearchScreen {
|
|
939
|
+
align: left bottom;
|
|
940
|
+
}
|
|
941
|
+
#skill-container {
|
|
942
|
+
width: 66%;
|
|
943
|
+
height: auto;
|
|
944
|
+
max-height: 80%;
|
|
945
|
+
padding: 1 2;
|
|
946
|
+
}
|
|
947
|
+
.field-label {
|
|
948
|
+
margin-top: 1;
|
|
949
|
+
}
|
|
950
|
+
.field-hint {
|
|
951
|
+
color: $text-muted;
|
|
952
|
+
text-style: italic;
|
|
953
|
+
text-wrap: wrap;
|
|
954
|
+
width: 1fr;
|
|
955
|
+
}
|
|
956
|
+
#skill-results {
|
|
957
|
+
height: auto;
|
|
958
|
+
max-height: 14;
|
|
959
|
+
display: none;
|
|
960
|
+
}
|
|
961
|
+
#selected-skills {
|
|
962
|
+
height: auto;
|
|
963
|
+
max-height: 6;
|
|
964
|
+
display: none;
|
|
965
|
+
}
|
|
966
|
+
.nav-bar {
|
|
967
|
+
margin-top: 1;
|
|
968
|
+
height: 1;
|
|
969
|
+
align: right middle;
|
|
970
|
+
}
|
|
971
|
+
.status-label {
|
|
972
|
+
color: $text-muted;
|
|
973
|
+
margin-top: 0;
|
|
974
|
+
}
|
|
975
|
+
"""
|
|
976
|
+
|
|
977
|
+
def __init__(self) -> None:
|
|
978
|
+
super().__init__()
|
|
979
|
+
self._results: list[dict[str, Any]] = []
|
|
980
|
+
self._selected: list[dict[str, Any]] = []
|
|
981
|
+
|
|
982
|
+
def compose(self) -> ComposeResult:
|
|
983
|
+
with FormScroll(id="skill-container"):
|
|
984
|
+
yield Label("[b]Step 7 of 8[/b] — Decision Hub Skills", classes="field-label")
|
|
985
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
986
|
+
yield Label(
|
|
987
|
+
"Search for skills to include in your decision-pack. "
|
|
988
|
+
"Describe what you need and press Enter. "
|
|
989
|
+
"You can skip this step.",
|
|
990
|
+
classes="field-hint",
|
|
991
|
+
)
|
|
992
|
+
yield Label("")
|
|
993
|
+
yield Input(id="skill-search-input", placeholder="Describe what skills you need...")
|
|
994
|
+
yield Label("", id="search-status", classes="status-label")
|
|
995
|
+
with Vertical(classes="selection-group"):
|
|
996
|
+
yield OptionList(id="skill-results")
|
|
997
|
+
yield Label("Tab to continue", classes="option-hint")
|
|
998
|
+
|
|
999
|
+
yield Label("Selected skills:", classes="field-label")
|
|
1000
|
+
with Vertical(classes="selection-group"):
|
|
1001
|
+
yield OptionList(id="selected-skills")
|
|
1002
|
+
yield Label("Tab to continue", classes="option-hint")
|
|
1003
|
+
|
|
1004
|
+
with Horizontal(classes="nav-bar nav-swap"):
|
|
1005
|
+
yield Button("Skip →", id="next-btn")
|
|
1006
|
+
yield Button("← Back", id="back-btn")
|
|
1007
|
+
|
|
1008
|
+
def on_mount(self) -> None:
|
|
1009
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
1010
|
+
self._selected = list(state.get("selected_skills", []))
|
|
1011
|
+
self._refresh_selected_display()
|
|
1012
|
+
|
|
1013
|
+
search_input: Input = self.query_one("#skill-search-input", Input)
|
|
1014
|
+
|
|
1015
|
+
# If the user wrote a custom description, use it for
|
|
1016
|
+
# automatic skill recommendations on mount.
|
|
1017
|
+
desc: str = state.get("description", "")
|
|
1018
|
+
name: str = state.get("name", "")
|
|
1019
|
+
auto_desc: str = f"dlab decision-pack: {name}"
|
|
1020
|
+
if desc and desc != auto_desc:
|
|
1021
|
+
search_input.value = desc
|
|
1022
|
+
self._do_search(desc)
|
|
1023
|
+
|
|
1024
|
+
search_input.focus()
|
|
1025
|
+
|
|
1026
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
1027
|
+
if event.input.id != "skill-search-input":
|
|
1028
|
+
return
|
|
1029
|
+
query: str = event.value.strip()
|
|
1030
|
+
if not query:
|
|
1031
|
+
return
|
|
1032
|
+
self._do_search(query)
|
|
1033
|
+
|
|
1034
|
+
@work(thread=True)
|
|
1035
|
+
def _do_search(self, query: str) -> None:
|
|
1036
|
+
"""Run natural-language skill search via Decision Hub."""
|
|
1037
|
+
status: Label = self.query_one("#search-status", Label)
|
|
1038
|
+
self.app.call_from_thread(status.update, "Searching...")
|
|
1039
|
+
|
|
1040
|
+
try:
|
|
1041
|
+
results: list[dict[str, Any]] = ask_skills(query)
|
|
1042
|
+
self._results = results
|
|
1043
|
+
self.app.call_from_thread(self._display_results, results)
|
|
1044
|
+
except Exception as e:
|
|
1045
|
+
self.app.call_from_thread(status.update, f"[red]Error: {e}[/red]")
|
|
1046
|
+
|
|
1047
|
+
def _display_results(self, results: list[dict[str, Any]]) -> None:
|
|
1048
|
+
status: Label = self.query_one("#search-status", Label)
|
|
1049
|
+
ol: OptionList = self.query_one("#skill-results", OptionList)
|
|
1050
|
+
ol.clear_options()
|
|
1051
|
+
|
|
1052
|
+
if not results:
|
|
1053
|
+
status.update("No results found.")
|
|
1054
|
+
ol.display = False
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
ol.display = True
|
|
1058
|
+
status.update(f"{len(results)} result(s) — select to add")
|
|
1059
|
+
for skill in results:
|
|
1060
|
+
name: str = f"{skill.get('org_slug', '?')}/{skill.get('skill_name', '?')}"
|
|
1061
|
+
reason: str = skill.get("reason", skill.get("description", ""))
|
|
1062
|
+
line: str = f" {name} [dim]{reason}[/dim]"
|
|
1063
|
+
ol.add_option(Option(line, id=name))
|
|
1064
|
+
|
|
1065
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
1066
|
+
if event.option_list.id == "skill-results":
|
|
1067
|
+
self._add_skill(event.option.id)
|
|
1068
|
+
elif event.option_list.id == "selected-skills":
|
|
1069
|
+
self._remove_skill(event.option.id)
|
|
1070
|
+
|
|
1071
|
+
def _add_skill(self, skill_id: str | None) -> None:
|
|
1072
|
+
if skill_id is None:
|
|
1073
|
+
return
|
|
1074
|
+
for skill in self._results:
|
|
1075
|
+
key: str = f"{skill.get('org_slug', '')}/{skill.get('skill_name', '')}"
|
|
1076
|
+
if key == skill_id:
|
|
1077
|
+
if not any(s.get("skill_name") == skill.get("skill_name") for s in self._selected):
|
|
1078
|
+
self._selected.append(skill)
|
|
1079
|
+
self._refresh_selected_display()
|
|
1080
|
+
return
|
|
1081
|
+
|
|
1082
|
+
def _remove_skill(self, skill_id: str | None) -> None:
|
|
1083
|
+
if skill_id is None:
|
|
1084
|
+
return
|
|
1085
|
+
self._selected = [
|
|
1086
|
+
s for s in self._selected
|
|
1087
|
+
if f"{s.get('org_slug', '')}/{s.get('skill_name', '')}" != skill_id
|
|
1088
|
+
]
|
|
1089
|
+
self._refresh_selected_display()
|
|
1090
|
+
|
|
1091
|
+
def _refresh_selected_display(self) -> None:
|
|
1092
|
+
ol: OptionList = self.query_one("#selected-skills", OptionList)
|
|
1093
|
+
ol.clear_options()
|
|
1094
|
+
if self._selected:
|
|
1095
|
+
ol.display = True
|
|
1096
|
+
for skill in self._selected:
|
|
1097
|
+
name: str = f"{skill.get('org_slug', '')}/{skill.get('skill_name', '')}"
|
|
1098
|
+
ol.add_option(Option(f"✓ {name} [dim](click to remove)[/dim]", id=name))
|
|
1099
|
+
self.query_one("#next-btn", Button).label = "Next →"
|
|
1100
|
+
else:
|
|
1101
|
+
ol.display = False
|
|
1102
|
+
self.query_one("#next-btn", Button).label = "Skip →"
|
|
1103
|
+
|
|
1104
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1105
|
+
if event.button.id == "back-btn":
|
|
1106
|
+
self.app.pop_screen()
|
|
1107
|
+
return
|
|
1108
|
+
if event.button.id == "next-btn":
|
|
1109
|
+
self.app.wizard_state["selected_skills"] = self._selected
|
|
1110
|
+
self.app.push_screen(SummaryScreen())
|
|
1111
|
+
|
|
1112
|
+
def action_go_back(self) -> None:
|
|
1113
|
+
self.app.pop_screen()
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
# ---------------------------------------------------------------------------
|
|
1117
|
+
# Screen 7: Summary & confirm
|
|
1118
|
+
# ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
class SummaryScreen(Screen):
|
|
1121
|
+
"""Show summary and confirm decision-pack creation."""
|
|
1122
|
+
|
|
1123
|
+
BINDINGS = [Binding("escape", "go_back", "Back")]
|
|
1124
|
+
|
|
1125
|
+
CSS = """
|
|
1126
|
+
SummaryScreen {
|
|
1127
|
+
align: left bottom;
|
|
1128
|
+
}
|
|
1129
|
+
#summary-container {
|
|
1130
|
+
width: 66%;
|
|
1131
|
+
height: auto;
|
|
1132
|
+
max-height: 80%;
|
|
1133
|
+
padding: 1 2;
|
|
1134
|
+
}
|
|
1135
|
+
.field-label {
|
|
1136
|
+
margin-top: 1;
|
|
1137
|
+
}
|
|
1138
|
+
.nav-bar {
|
|
1139
|
+
margin-top: 1;
|
|
1140
|
+
height: 1;
|
|
1141
|
+
align: right middle;
|
|
1142
|
+
}
|
|
1143
|
+
#create-btn {
|
|
1144
|
+
min-width: 16;
|
|
1145
|
+
}
|
|
1146
|
+
"""
|
|
1147
|
+
|
|
1148
|
+
def compose(self) -> ComposeResult:
|
|
1149
|
+
step: str = self._step_label()
|
|
1150
|
+
with FormScroll(id="summary-container"):
|
|
1151
|
+
yield Label(f"[b]{step}[/b] — Review & Create", classes="field-label")
|
|
1152
|
+
yield Label("Tab / Shift+Tab to navigate | Ctrl+Q to quit", classes="field-hint")
|
|
1153
|
+
yield Static(id="summary-content")
|
|
1154
|
+
yield Label("", id="result-label")
|
|
1155
|
+
yield Button("Delete & Overwrite", id="overwrite-btn", variant="error")
|
|
1156
|
+
with Horizontal(classes="nav-bar nav-swap-wide"):
|
|
1157
|
+
yield Button("Create decision-pack", id="create-btn", variant="success")
|
|
1158
|
+
yield Button("Done", id="done-btn", variant="primary")
|
|
1159
|
+
yield Button("Keep Partial", id="keep-btn")
|
|
1160
|
+
yield Button("← Back", id="back-btn")
|
|
1161
|
+
|
|
1162
|
+
def _step_label(self) -> str:
|
|
1163
|
+
skills_enabled: bool = self.app.wizard_state.get("skeletons", {}).get("skills", True)
|
|
1164
|
+
total: int = 8 if skills_enabled else 7
|
|
1165
|
+
return f"Step {total} of {total}"
|
|
1166
|
+
|
|
1167
|
+
def on_mount(self) -> None:
|
|
1168
|
+
self.query_one("#done-btn", Button).display = False
|
|
1169
|
+
self.query_one("#keep-btn", Button).display = False
|
|
1170
|
+
self.query_one("#overwrite-btn", Button).display = False
|
|
1171
|
+
self._show_review()
|
|
1172
|
+
self.query_one("#create-btn", Button).focus()
|
|
1173
|
+
|
|
1174
|
+
def _show_review(self) -> None:
|
|
1175
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
1176
|
+
skeletons: dict[str, bool] = state.get("skeletons", {})
|
|
1177
|
+
permissions: dict[str, str] = state.get("permissions", {})
|
|
1178
|
+
selected_skills: list[dict[str, Any]] = state.get("selected_skills", [])
|
|
1179
|
+
|
|
1180
|
+
enabled_skels: list[str] = [k for k, v in skeletons.items() if v]
|
|
1181
|
+
skel_str: str = ", ".join(enabled_skels) if enabled_skels else "none"
|
|
1182
|
+
|
|
1183
|
+
allowed: list[str] = [k for k, v in permissions.items() if v == "allow"]
|
|
1184
|
+
denied: list[str] = [k for k, v in permissions.items() if v == "deny"]
|
|
1185
|
+
allowed_str: str = ", ".join(allowed) if allowed else "none"
|
|
1186
|
+
denied_str: str = ", ".join(denied) if denied else "none"
|
|
1187
|
+
|
|
1188
|
+
skill_names: list[str] = [
|
|
1189
|
+
f"{s.get('org_slug', '')}/{s.get('skill_name', '')}"
|
|
1190
|
+
for s in selected_skills
|
|
1191
|
+
]
|
|
1192
|
+
skill_str: str = ", ".join(skill_names) if skill_names else "none"
|
|
1193
|
+
|
|
1194
|
+
pkg_mgr: str = state.get("package_manager", "pip")
|
|
1195
|
+
python_lib_name: str = state.get("python_lib_name", "")
|
|
1196
|
+
modal: bool = state.get("modal_integration", False)
|
|
1197
|
+
extras: list[str] = []
|
|
1198
|
+
if python_lib_name:
|
|
1199
|
+
extras.append(f"python lib ({python_lib_name})")
|
|
1200
|
+
if modal:
|
|
1201
|
+
extras.append("modal integration")
|
|
1202
|
+
extras_str: str = ", ".join(extras) if extras else "none"
|
|
1203
|
+
|
|
1204
|
+
summary: str = f"""
|
|
1205
|
+
[b]decision-pack[/b]
|
|
1206
|
+
Name: {state['name']}
|
|
1207
|
+
Description: {state.get('description', '')}
|
|
1208
|
+
|
|
1209
|
+
[b]Container[/b]
|
|
1210
|
+
Package mgr: {pkg_mgr}
|
|
1211
|
+
Base image: {state.get('base_image', '')}
|
|
1212
|
+
Extras: {extras_str}
|
|
1213
|
+
|
|
1214
|
+
[b]Model[/b]
|
|
1215
|
+
Default: {state.get('default_model', '')}
|
|
1216
|
+
|
|
1217
|
+
[b]Permissions[/b]
|
|
1218
|
+
Allowed: {allowed_str}
|
|
1219
|
+
Denied: {denied_str}
|
|
1220
|
+
|
|
1221
|
+
[b]Skeletons[/b]
|
|
1222
|
+
Enabled: {skel_str}
|
|
1223
|
+
|
|
1224
|
+
[b]Hub Skills[/b]
|
|
1225
|
+
Selected: {skill_str}
|
|
1226
|
+
"""
|
|
1227
|
+
self.query_one("#summary-content", Static).update(summary)
|
|
1228
|
+
|
|
1229
|
+
def _show_walkthrough(self, dpack_path: Path) -> None:
|
|
1230
|
+
"""Replace summary with a walkthrough of what was created."""
|
|
1231
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
1232
|
+
skeletons: dict[str, bool] = state.get("skeletons", {})
|
|
1233
|
+
agent_name: str = state.get("agent_name", "orchestrator")
|
|
1234
|
+
pkg_mgr: str = state.get("package_manager", "pip")
|
|
1235
|
+
selected_skills: list[dict[str, Any]] = state.get("selected_skills", [])
|
|
1236
|
+
|
|
1237
|
+
# Determine env file name
|
|
1238
|
+
env_files: dict[str, str] = {
|
|
1239
|
+
"conda": "environment.yml",
|
|
1240
|
+
"pixi": "pixi.toml",
|
|
1241
|
+
}
|
|
1242
|
+
env_file: str = env_files.get(pkg_mgr, "requirements.txt")
|
|
1243
|
+
|
|
1244
|
+
lines: list[str] = [
|
|
1245
|
+
f"[green bold]decision-pack created: {dpack_path}[/green bold]",
|
|
1246
|
+
"",
|
|
1247
|
+
"[b]What was created:[/b]",
|
|
1248
|
+
f" config.yaml decision-pack configuration (name, model, docker image)",
|
|
1249
|
+
f" docker/Dockerfile Container setup ({pkg_mgr})",
|
|
1250
|
+
f" docker/{env_file:<20s} Package dependencies",
|
|
1251
|
+
f" opencode/opencode.json Points to your main agent: {agent_name}",
|
|
1252
|
+
f" opencode/agents/{agent_name}.md",
|
|
1253
|
+
f" Your main agent's system prompt",
|
|
1254
|
+
]
|
|
1255
|
+
|
|
1256
|
+
python_lib_name: str = state.get("python_lib_name", "")
|
|
1257
|
+
if python_lib_name:
|
|
1258
|
+
lines.append(f" docker/{python_lib_name}/")
|
|
1259
|
+
lines.append(f" Python library package")
|
|
1260
|
+
if state.get("modal_integration"):
|
|
1261
|
+
lines.append(f" docker/modal_app/ Modal serverless compute")
|
|
1262
|
+
|
|
1263
|
+
has_subagents: bool = skeletons.get("subagents", False) or skeletons.get("parallel_agents", False)
|
|
1264
|
+
if has_subagents:
|
|
1265
|
+
lines.append(f" opencode/agents/example-worker.md")
|
|
1266
|
+
lines.append(f" Example subagent (rename & customize)")
|
|
1267
|
+
if skeletons.get("parallel_agents"):
|
|
1268
|
+
lines.append(f" opencode/parallel_agents/example-worker.yaml")
|
|
1269
|
+
lines.append(f" Parallel agent config")
|
|
1270
|
+
if skeletons.get("tools"):
|
|
1271
|
+
lines.append(f" opencode/tools/example-tool.ts")
|
|
1272
|
+
lines.append(f" Example custom tool")
|
|
1273
|
+
if skeletons.get("skills"):
|
|
1274
|
+
lines.append(f" opencode/skills/example-skill/SKILL.md")
|
|
1275
|
+
lines.append(f" Example skill")
|
|
1276
|
+
for skill in selected_skills:
|
|
1277
|
+
sname: str = skill.get("skill_name", "?")
|
|
1278
|
+
lines.append(f" opencode/skills/{sname}/")
|
|
1279
|
+
lines.append(f" Downloaded from Decision Hub")
|
|
1280
|
+
|
|
1281
|
+
lines.extend([
|
|
1282
|
+
"",
|
|
1283
|
+
"[b]Next steps:[/b]",
|
|
1284
|
+
f" 1. Edit opencode/agents/{agent_name}.md to write your agent's system prompt",
|
|
1285
|
+
f" 2. Add your data files to a directory and run:",
|
|
1286
|
+
f" dlab --dpack {dpack_path} --data ./your-data --prompt \"Your task\"",
|
|
1287
|
+
f" 3. Install as a shortcut:",
|
|
1288
|
+
f" dlab install {dpack_path}",
|
|
1289
|
+
])
|
|
1290
|
+
|
|
1291
|
+
self.query_one("#summary-content", Static).update("\n".join(lines))
|
|
1292
|
+
|
|
1293
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1294
|
+
if event.button.id == "back-btn":
|
|
1295
|
+
self.app.pop_screen()
|
|
1296
|
+
return
|
|
1297
|
+
if event.button.id in ("done-btn", "keep-btn"):
|
|
1298
|
+
self.app.exit()
|
|
1299
|
+
return
|
|
1300
|
+
if event.button.id == "overwrite-btn":
|
|
1301
|
+
self.app.wizard_state["overwrite_existing"] = True
|
|
1302
|
+
self.query_one("#result-label", Label).update(
|
|
1303
|
+
"[green]decision-pack will be overwritten[/green]"
|
|
1304
|
+
)
|
|
1305
|
+
self.query_one("#overwrite-btn", Button).display = False
|
|
1306
|
+
return
|
|
1307
|
+
if event.button.id != "create-btn":
|
|
1308
|
+
return
|
|
1309
|
+
|
|
1310
|
+
result_label: Label = self.query_one("#result-label", Label)
|
|
1311
|
+
state: dict[str, Any] = self.app.wizard_state
|
|
1312
|
+
output_dir: Path = Path(self.app.output_dir)
|
|
1313
|
+
dpack_dir: Path = output_dir / state["name"]
|
|
1314
|
+
|
|
1315
|
+
# Pre-create collision check
|
|
1316
|
+
if dpack_dir.exists() and not state.get("overwrite_existing"):
|
|
1317
|
+
result_label.update("[red]decision-pack directory exists![/red]")
|
|
1318
|
+
self.query_one("#overwrite-btn", Button).display = True
|
|
1319
|
+
return
|
|
1320
|
+
|
|
1321
|
+
self._run_create(output_dir, state)
|
|
1322
|
+
|
|
1323
|
+
@work(thread=True)
|
|
1324
|
+
def _run_create(self, output_dir: Path, state: dict[str, Any]) -> None:
|
|
1325
|
+
"""Run decision-pack creation in background with progress feedback."""
|
|
1326
|
+
def _on_progress(msg: str) -> None:
|
|
1327
|
+
self.app.call_from_thread(
|
|
1328
|
+
self.query_one("#result-label", Label).update, msg
|
|
1329
|
+
)
|
|
1330
|
+
|
|
1331
|
+
try:
|
|
1332
|
+
dpack_path: Path = generate_dpack(output_dir, dict(state), on_progress=_on_progress)
|
|
1333
|
+
self.app.call_from_thread(self._on_create_success, dpack_path)
|
|
1334
|
+
except Exception as e:
|
|
1335
|
+
self.app.call_from_thread(self._on_create_error, str(e))
|
|
1336
|
+
|
|
1337
|
+
def _on_create_success(self, dpack_path: Path) -> None:
|
|
1338
|
+
self.query_one("#result-label", Label).update("")
|
|
1339
|
+
self._show_walkthrough(dpack_path)
|
|
1340
|
+
self.query_one("#create-btn", Button).display = False
|
|
1341
|
+
self.query_one("#back-btn", Button).display = False
|
|
1342
|
+
self.query_one("#done-btn", Button).display = True
|
|
1343
|
+
|
|
1344
|
+
def _on_create_error(self, error_msg: str) -> None:
|
|
1345
|
+
self.query_one("#result-label", Label).update(f"[red]Error: {error_msg}[/red]")
|
|
1346
|
+
self.query_one("#create-btn", Button).display = False
|
|
1347
|
+
self.query_one("#keep-btn", Button).display = True
|
|
1348
|
+
self.query_one("#back-btn", Button).label = "← Go Back (fix & retry)"
|
|
1349
|
+
|
|
1350
|
+
def action_go_back(self) -> None:
|
|
1351
|
+
self.app.pop_screen()
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
# ---------------------------------------------------------------------------
|
|
1355
|
+
# Main App
|
|
1356
|
+
# ---------------------------------------------------------------------------
|
|
1357
|
+
|
|
1358
|
+
class CreateDpackApp(App):
|
|
1359
|
+
"""Multi-screen wizard for creating a new decision-pack directory."""
|
|
1360
|
+
|
|
1361
|
+
TITLE = "dlab create-dpack"
|
|
1362
|
+
BINDINGS = [
|
|
1363
|
+
Binding("ctrl+q", "quit", "Quit", priority=True),
|
|
1364
|
+
Binding("down", "focus_next", show=False),
|
|
1365
|
+
Binding("up", "focus_previous", show=False),
|
|
1366
|
+
Binding("left", "focus_previous", show=False),
|
|
1367
|
+
Binding("right", "focus_next", show=False),
|
|
1368
|
+
]
|
|
1369
|
+
theme = "monokai"
|
|
1370
|
+
|
|
1371
|
+
CSS = """
|
|
1372
|
+
/* --- Flat widget overrides ------------------------------------------ */
|
|
1373
|
+
Input {
|
|
1374
|
+
border: none;
|
|
1375
|
+
border-left: tall $accent;
|
|
1376
|
+
height: 1;
|
|
1377
|
+
padding: 0 1;
|
|
1378
|
+
background: $surface;
|
|
1379
|
+
&:focus {
|
|
1380
|
+
border: none;
|
|
1381
|
+
border-left: tall $accent;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
Button {
|
|
1385
|
+
min-width: 10;
|
|
1386
|
+
border: none;
|
|
1387
|
+
background: $surface;
|
|
1388
|
+
&:hover {
|
|
1389
|
+
background: $primary;
|
|
1390
|
+
}
|
|
1391
|
+
&.-primary {
|
|
1392
|
+
background: $primary-muted;
|
|
1393
|
+
border: none;
|
|
1394
|
+
&:hover { background: $primary; border: none; }
|
|
1395
|
+
}
|
|
1396
|
+
&.-success {
|
|
1397
|
+
background: $success-muted;
|
|
1398
|
+
border: none;
|
|
1399
|
+
&:hover { background: $success; border: none; }
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
OptionList {
|
|
1403
|
+
border: none;
|
|
1404
|
+
border-left: tall $accent;
|
|
1405
|
+
background: $surface;
|
|
1406
|
+
scrollbar-size: 1 1;
|
|
1407
|
+
}
|
|
1408
|
+
Checkbox {
|
|
1409
|
+
border: none;
|
|
1410
|
+
background: transparent;
|
|
1411
|
+
height: 1;
|
|
1412
|
+
padding: 0;
|
|
1413
|
+
}
|
|
1414
|
+
Checkbox > .toggle--button {
|
|
1415
|
+
color: $text-muted;
|
|
1416
|
+
}
|
|
1417
|
+
Checkbox.-on > .toggle--button {
|
|
1418
|
+
color: $success;
|
|
1419
|
+
}
|
|
1420
|
+
#model-results, #skill-results, #selected-skills {
|
|
1421
|
+
margin-top: 1;
|
|
1422
|
+
}
|
|
1423
|
+
.cb-group {
|
|
1424
|
+
border-left: tall $accent;
|
|
1425
|
+
background: $surface;
|
|
1426
|
+
padding: 0 1;
|
|
1427
|
+
height: auto;
|
|
1428
|
+
}
|
|
1429
|
+
#overwrite-btn {
|
|
1430
|
+
color: $text;
|
|
1431
|
+
}
|
|
1432
|
+
#collision-bar {
|
|
1433
|
+
height: auto;
|
|
1434
|
+
}
|
|
1435
|
+
/* TODO: The nav-swap offset hack visually swaps button positions so that
|
|
1436
|
+
Next appears right and Back appears left, while keeping Next first in
|
|
1437
|
+
focus order. This is brittle (hardcoded pixel offsets). Replace with a
|
|
1438
|
+
proper solution when Textual adds CSS `order` or when we rename decision-packs. */
|
|
1439
|
+
.nav-swap > #next-btn {
|
|
1440
|
+
offset: 10 0;
|
|
1441
|
+
}
|
|
1442
|
+
.nav-swap > #back-btn {
|
|
1443
|
+
offset: -10 0;
|
|
1444
|
+
}
|
|
1445
|
+
.nav-swap-wide > #create-btn {
|
|
1446
|
+
offset: 12 0;
|
|
1447
|
+
}
|
|
1448
|
+
.nav-swap-wide > #back-btn {
|
|
1449
|
+
offset: -18 0;
|
|
1450
|
+
}
|
|
1451
|
+
.selection-group {
|
|
1452
|
+
height: auto;
|
|
1453
|
+
}
|
|
1454
|
+
.option-hint {
|
|
1455
|
+
display: none;
|
|
1456
|
+
color: $text-muted;
|
|
1457
|
+
text-style: italic;
|
|
1458
|
+
height: 1;
|
|
1459
|
+
}
|
|
1460
|
+
.selection-group:focus-within .option-hint {
|
|
1461
|
+
display: block;
|
|
1462
|
+
}
|
|
1463
|
+
"""
|
|
1464
|
+
|
|
1465
|
+
def __init__(self, output_dir: str = ".") -> None:
|
|
1466
|
+
super().__init__()
|
|
1467
|
+
self.output_dir: str = output_dir
|
|
1468
|
+
self.wizard_state: dict[str, Any] = {}
|
|
1469
|
+
|
|
1470
|
+
def on_mount(self) -> None:
|
|
1471
|
+
self.push_screen(BasicsScreen())
|