docker-orb 1.0.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.
@@ -0,0 +1,668 @@
1
+ """
2
+ Shared viewer widgets:
3
+ - DragResizeHeader — mixin: click toggles collapse, vertical drag resizes
4
+ - StringEditModal — modal editor for F/H/M keybinds
5
+ - ConfirmDialog — yes/no prompt for destructive actions
6
+ - SaveDialog — filename prompt for Ctrl+S
7
+ - ContainerSelectorModal — add/remove services from the live stream
8
+ - PaneSizeModal — set pane heights by percentage (L keybind)
9
+ - JsonDetailModal — full pretty-printed JSON for a clicked log line
10
+ - MonitorContextModal — ± context lines around a clicked monitor hit
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from rich.text import Text
15
+ from textual import events
16
+ from textual.app import ComposeResult
17
+ from textual.binding import Binding
18
+ from textual.containers import Horizontal, ScrollableContainer, Vertical
19
+ from textual.content import Content
20
+ from textual.screen import ModalScreen
21
+ from textual.widgets import Button, Checkbox, Input, Label, Static
22
+
23
+ from ..config import parse_string_input
24
+ from ..models import LogLine
25
+
26
+
27
+ # ─── Draggable/collapsible panel header ──────────────────────────────────────
28
+
29
+ class DragResizeHeader(Static):
30
+ """
31
+ Mixin for panel header bars: a plain click still toggles collapse (as
32
+ before); a vertical drag resizes the panel by calling
33
+ `self.app.resize_panel(self.parent, delta)` for each row of movement.
34
+ The app owns the actual size math (it needs visibility into every panel
35
+ to enforce the main stream's minimum height), this widget just reports
36
+ drag deltas.
37
+ """
38
+
39
+ def __init__(self, *args, **kwargs) -> None:
40
+ super().__init__(*args, **kwargs)
41
+ self._drag_start_y: int | None = None
42
+ self._dragging = False
43
+
44
+ def on_mouse_down(self, event: events.MouseDown) -> None:
45
+ if getattr(self.parent, "_collapsed", False):
46
+ return # collapsed panels only respond to a plain click (expand)
47
+ self._drag_start_y = event.screen_y
48
+ self._dragging = False
49
+ self.capture_mouse()
50
+
51
+ def on_mouse_move(self, event: events.MouseMove) -> None:
52
+ if self._drag_start_y is None:
53
+ return
54
+ delta = event.screen_y - self._drag_start_y
55
+ if delta == 0:
56
+ return
57
+ self._dragging = True
58
+ app = self.app
59
+ if hasattr(app, "resize_panel"):
60
+ app.resize_panel(self.parent, delta)
61
+ self._drag_start_y = event.screen_y
62
+
63
+ def on_mouse_up(self, event: events.MouseUp) -> None:
64
+ if self._drag_start_y is not None:
65
+ self.release_mouse()
66
+ was_dragging = self._dragging
67
+ self._drag_start_y = None
68
+ self._dragging = False
69
+ if not was_dragging:
70
+ self.parent.toggle_collapsed() # type: ignore[union-attr]
71
+
72
+
73
+ # ─── String editor modal (F / H / M) ─────────────────────────────────────────
74
+
75
+ class StringEditModal(ModalScreen[tuple[list[str], bool] | None]):
76
+ """
77
+ Modal opened by F / H / M — edit filters, highlights, or monitors.
78
+ Shows saved + currently-active strings as checkboxes (check to activate,
79
+ uncheck to deactivate — same pattern as the wizard's Patterns tab), plus
80
+ an input to add brand-new ones. Dismisses with (new_strings, save_to_settings),
81
+ or None if cancelled.
82
+ """
83
+
84
+ DEFAULT_CSS = """
85
+ StringEditModal {
86
+ align: center middle;
87
+ }
88
+ #string-edit-dialog {
89
+ background: $surface;
90
+ border: thick $accent;
91
+ padding: 1 2;
92
+ width: 70;
93
+ height: auto;
94
+ max-height: 80%;
95
+ }
96
+ #string-edit-title {
97
+ text-style: bold;
98
+ color: $accent;
99
+ margin-bottom: 1;
100
+ }
101
+ #string-edit-hint {
102
+ color: $text-muted;
103
+ margin-bottom: 1;
104
+ }
105
+ #string-edit-checks-scroll {
106
+ height: auto;
107
+ max-height: 14;
108
+ border: tall $panel;
109
+ margin-bottom: 1;
110
+ }
111
+ #string-edit-checks {
112
+ height: auto;
113
+ }
114
+ #string-edit-input {
115
+ margin-bottom: 1;
116
+ }
117
+ #string-edit-save {
118
+ margin-bottom: 1;
119
+ }
120
+ """
121
+
122
+ BINDINGS = [
123
+ Binding("escape", "cancel", "Cancel"),
124
+ Binding("enter", "confirm", "Confirm"),
125
+ ]
126
+
127
+ def __init__(self, category: str, current: list[str], saved: list[str], **kwargs) -> None:
128
+ super().__init__(**kwargs)
129
+ self._category = category
130
+ self._active = set(current)
131
+ # Saved list first (stable, familiar order), then any currently-active
132
+ # strings that aren't saved (e.g. typed in ad-hoc without saving) so
133
+ # nothing currently in effect silently disappears from the list.
134
+ self._options = list(saved) + [s for s in current if s not in saved]
135
+
136
+ def compose(self) -> ComposeResult:
137
+ with Vertical(id="string-edit-dialog"):
138
+ yield Static(f"Edit {self._category}", id="string-edit-title")
139
+ yield Static(
140
+ "[dim]Check to activate · Enter to apply · Esc to cancel[/dim]",
141
+ id="string-edit-hint",
142
+ markup=True,
143
+ )
144
+ yield ScrollableContainer(
145
+ Vertical(id="string-edit-checks"),
146
+ id="string-edit-checks-scroll",
147
+ )
148
+ yield Input(id="string-edit-input", placeholder="Add new (comma-separated) …")
149
+ yield Checkbox("Save new to saved settings", id="string-edit-save", value=True)
150
+ yield Horizontal(
151
+ Button("Apply [Enter]", variant="primary", id="string-edit-apply"),
152
+ Button("Cancel [Esc]", id="string-edit-cancel"),
153
+ )
154
+
155
+ def on_mount(self) -> None:
156
+ container = self.query_one("#string-edit-checks", Vertical)
157
+ if not self._options:
158
+ container.mount(Label("[dim](none saved yet)[/dim]", markup=True))
159
+ else:
160
+ for s in self._options:
161
+ # Content(s) (not the raw str) — Checkbox labels parse Textual
162
+ # markup by default, and a pattern like "[debug]" is valid
163
+ # (empty) markup syntax that would otherwise render blank.
164
+ container.mount(
165
+ Checkbox(Content(s), value=(s in self._active), id=f"str-{abs(hash(s))}")
166
+ )
167
+ self.query_one("#string-edit-input", Input).focus()
168
+
169
+ def _checked(self) -> list[str]:
170
+ result = []
171
+ for s in self._options:
172
+ try:
173
+ if self.query_one(f"#str-{abs(hash(s))}", Checkbox).value:
174
+ result.append(s)
175
+ except Exception:
176
+ pass
177
+ return result
178
+
179
+ def _apply(self) -> None:
180
+ raw = self.query_one("#string-edit-input", Input).value.strip()
181
+ new_strings = parse_string_input(raw) if raw else []
182
+ checked = self._checked()
183
+ merged = checked + [s for s in new_strings if s not in checked]
184
+ save = self.query_one("#string-edit-save", Checkbox).value and bool(new_strings)
185
+ self.dismiss((merged, save))
186
+
187
+ def on_button_pressed(self, event: Button.Pressed) -> None:
188
+ if event.button.id == "string-edit-cancel":
189
+ self.dismiss(None)
190
+ elif event.button.id == "string-edit-apply":
191
+ self._apply()
192
+
193
+ def on_input_submitted(self, event: Input.Submitted) -> None:
194
+ if event.input.id == "string-edit-input":
195
+ self._apply()
196
+
197
+ def action_confirm(self) -> None:
198
+ self._apply()
199
+
200
+ def action_cancel(self) -> None:
201
+ self.dismiss(None)
202
+
203
+
204
+ # ─── Confirm dialog ────────────────────────────────────────────────────────────
205
+
206
+ class ConfirmDialog(ModalScreen[bool]):
207
+ """Simple yes/no modal for destructive actions."""
208
+
209
+ BINDINGS = [
210
+ Binding("y", "confirm", "Yes"),
211
+ Binding("n", "cancel", "No"),
212
+ Binding("escape", "cancel", "No"),
213
+ ]
214
+
215
+ def __init__(self, message: str, **kwargs) -> None:
216
+ super().__init__(**kwargs)
217
+ self._message = message
218
+
219
+ def compose(self) -> ComposeResult:
220
+ yield Static(self._message, id="confirm-msg")
221
+ yield Horizontal(
222
+ Button("Yes [Y]", variant="error", id="confirm-yes"),
223
+ Button("No [N]", variant="primary", id="confirm-no"),
224
+ )
225
+
226
+ def on_button_pressed(self, event: Button.Pressed) -> None:
227
+ self.dismiss(event.button.id == "confirm-yes")
228
+
229
+ def action_confirm(self) -> None:
230
+ self.dismiss(True)
231
+
232
+ def action_cancel(self) -> None:
233
+ self.dismiss(False)
234
+
235
+
236
+ # ─── Save dialog ──────────────────────────────────────────────────────────────
237
+
238
+ class SaveDialog(ModalScreen[str | None]):
239
+ """Filename prompt for Ctrl+S log save."""
240
+
241
+ BINDINGS = [
242
+ Binding("escape", "cancel", "Cancel"),
243
+ ]
244
+
245
+ def __init__(self, default_name: str, **kwargs) -> None:
246
+ super().__init__(**kwargs)
247
+ self._default = default_name
248
+
249
+ def compose(self) -> ComposeResult:
250
+ yield Static("Save log buffer to file: [dim]Enter to save · Esc to cancel[/dim]", id="save-label", markup=True)
251
+ yield Input(value=self._default, id="save-input")
252
+ yield Horizontal(
253
+ Button("Save [Enter]", variant="primary", id="save-go"),
254
+ Button("Cancel [Esc]", id="save-cancel"),
255
+ )
256
+
257
+ def on_mount(self) -> None:
258
+ inp = self.query_one("#save-input", Input)
259
+ inp.focus()
260
+ inp.cursor_position = len(self._default)
261
+
262
+ def on_button_pressed(self, event: Button.Pressed) -> None:
263
+ if event.button.id == "save-cancel":
264
+ self.dismiss(None)
265
+ elif event.button.id == "save-go":
266
+ path = self.query_one("#save-input", Input).value.strip()
267
+ self.dismiss(path or None)
268
+
269
+ def on_input_submitted(self, event: Input.Submitted) -> None:
270
+ path = event.value.strip()
271
+ self.dismiss(path or None)
272
+
273
+ def action_cancel(self) -> None:
274
+ self.dismiss(None)
275
+
276
+
277
+ # ─── Container selector modal (P keybind) ──────────────────────────────────────────
278
+
279
+ class ContainerSelectorModal(ModalScreen[list[str] | None]):
280
+ """
281
+ Modal for adding/removing services from the live log stream.
282
+ Returns the new list of selected service names, or None if cancelled.
283
+ """
284
+
285
+ DEFAULT_CSS = """
286
+ ContainerSelectorModal {
287
+ align: center middle;
288
+ }
289
+ #container-selector-dialog {
290
+ background: $surface;
291
+ border: thick $accent;
292
+ padding: 1 2;
293
+ width: 60;
294
+ height: auto;
295
+ max-height: 80%;
296
+ }
297
+ #container-selector-title {
298
+ text-style: bold;
299
+ color: $accent;
300
+ margin-bottom: 1;
301
+ }
302
+ #container-selector-hint {
303
+ color: $text-muted;
304
+ margin-bottom: 1;
305
+ }
306
+ #container-checks-scroll {
307
+ height: auto;
308
+ max-height: 20;
309
+ border: tall $panel;
310
+ margin-bottom: 1;
311
+ }
312
+ """
313
+
314
+ BINDINGS = [
315
+ Binding("escape", "cancel", "Cancel"),
316
+ Binding("enter", "confirm", "Confirm"),
317
+ ]
318
+
319
+ def __init__(
320
+ self,
321
+ project: str,
322
+ active_services: list[str],
323
+ **kwargs,
324
+ ) -> None:
325
+ super().__init__(**kwargs)
326
+ self._project = project
327
+ self._active = set(active_services)
328
+ self._all_services: list[tuple[str, int]] = [] # (name, container_count)
329
+
330
+ def compose(self) -> ComposeResult:
331
+ with Vertical(id="container-selector-dialog"):
332
+ yield Static("Manage log streams", id="container-selector-title")
333
+ yield Static(
334
+ "[dim]Check services to stream · Esc to cancel · Enter to apply[/dim]",
335
+ id="container-selector-hint",
336
+ markup=True,
337
+ )
338
+ yield ScrollableContainer(
339
+ Vertical(id="container-checks"),
340
+ id="container-checks-scroll",
341
+ )
342
+ yield Horizontal(
343
+ Button("Apply [Enter]", variant="primary", id="container-sel-apply"),
344
+ Button("Cancel [Esc]", id="container-sel-cancel"),
345
+ )
346
+
347
+ def on_mount(self) -> None:
348
+ from .. import compose as k
349
+ try:
350
+ deps = k.get_services(self._project)
351
+ self._all_services = [(d.name, d.container_count) for d in deps]
352
+ except Exception:
353
+ self._all_services = [(name, 0) for name in self._active]
354
+
355
+ container = self.query_one("#container-checks", Vertical)
356
+ for name, count in self._all_services:
357
+ label = f"{name} ({count} container{'s' if count != 1 else ''})"
358
+ container.mount(
359
+ Checkbox(label, value=(name in self._active), id=f"pdep-{name}")
360
+ )
361
+
362
+ def _selected(self) -> list[str]:
363
+ result = []
364
+ for name, _ in self._all_services:
365
+ try:
366
+ cb = self.query_one(f"#pdep-{name}", Checkbox)
367
+ if cb.value:
368
+ result.append(name)
369
+ except Exception:
370
+ pass
371
+ return result
372
+
373
+ def on_button_pressed(self, event: Button.Pressed) -> None:
374
+ if event.button.id == "container-sel-apply":
375
+ self.dismiss(self._selected())
376
+ elif event.button.id == "container-sel-cancel":
377
+ self.dismiss(None)
378
+
379
+ def action_confirm(self) -> None:
380
+ self.dismiss(self._selected())
381
+
382
+ def action_cancel(self) -> None:
383
+ self.dismiss(None)
384
+
385
+
386
+ # ─── Pane size modal (L keybind) ──────────────────────────────────────────────
387
+
388
+ class PaneSizeModal(ModalScreen[dict[str, int] | None]):
389
+ """
390
+ Keyboard/click-driven alternative to dragging a panel's header border —
391
+ set each currently-visible, non-collapsed side panel's height as a
392
+ percentage of the viewer. Returns {label: percent} on Apply, or None if
393
+ cancelled. The app validates and applies the result (it owns the
394
+ MainStreamPanel-minimum-height math already used by drag-resize).
395
+ """
396
+
397
+ DEFAULT_CSS = """
398
+ PaneSizeModal {
399
+ align: center middle;
400
+ }
401
+ #pane-size-dialog {
402
+ background: $surface;
403
+ border: thick $accent;
404
+ padding: 1 2;
405
+ width: 50;
406
+ height: auto;
407
+ }
408
+ #pane-size-title {
409
+ text-style: bold;
410
+ color: $accent;
411
+ margin-bottom: 1;
412
+ }
413
+ #pane-size-hint {
414
+ color: $text-muted;
415
+ margin-bottom: 1;
416
+ }
417
+ .pane-size-row {
418
+ height: auto;
419
+ margin-bottom: 1;
420
+ }
421
+ .pane-size-row Label {
422
+ width: 14;
423
+ padding-top: 1;
424
+ }
425
+ .pane-size-row Input {
426
+ width: 10;
427
+ }
428
+ .pane-size-row Static {
429
+ width: auto;
430
+ padding: 1 0 0 1;
431
+ }
432
+ """
433
+
434
+ BINDINGS = [
435
+ Binding("escape", "cancel", "Cancel"),
436
+ Binding("enter", "confirm", "Confirm"),
437
+ ]
438
+
439
+ def __init__(self, entries: list[tuple[str, int]], **kwargs) -> None:
440
+ """entries: (label, current_percent) for each visible, non-collapsed side panel."""
441
+ super().__init__(**kwargs)
442
+ self._entries = entries
443
+
444
+ def compose(self) -> ComposeResult:
445
+ with Vertical(id="pane-size-dialog"):
446
+ yield Static("Pane sizes", id="pane-size-title")
447
+ yield Static(
448
+ "[dim]Percent of viewer height for each visible pane · "
449
+ "the main stream takes whatever's left · "
450
+ "Enter to apply · Esc to cancel[/dim]",
451
+ id="pane-size-hint",
452
+ markup=True,
453
+ )
454
+ for label, pct in self._entries:
455
+ with Horizontal(classes="pane-size-row"):
456
+ yield Label(label)
457
+ yield Input(value=str(pct), id=f"pane-size-{label.lower()}", type="integer")
458
+ yield Static("%")
459
+ yield Horizontal(
460
+ Button("Apply [Enter]", variant="primary", id="pane-size-apply"),
461
+ Button("Cancel [Esc]", id="pane-size-cancel"),
462
+ )
463
+
464
+ def on_mount(self) -> None:
465
+ if self._entries:
466
+ first_label = self._entries[0][0]
467
+ self.query_one(f"#pane-size-{first_label.lower()}", Input).focus()
468
+
469
+ def _collect(self) -> dict[str, int] | None:
470
+ result: dict[str, int] = {}
471
+ for label, _pct in self._entries:
472
+ raw = self.query_one(f"#pane-size-{label.lower()}", Input).value.strip()
473
+ try:
474
+ pct = int(raw)
475
+ except ValueError:
476
+ self.notify(f"{label}: enter a whole number percentage.", severity="warning")
477
+ return None
478
+ if not (0 <= pct <= 100):
479
+ self.notify(f"{label}: percentage must be between 0 and 100.", severity="warning")
480
+ return None
481
+ result[label] = pct
482
+ return result
483
+
484
+ def _apply(self) -> None:
485
+ result = self._collect()
486
+ if result is not None:
487
+ self.dismiss(result)
488
+
489
+ def on_button_pressed(self, event: Button.Pressed) -> None:
490
+ if event.button.id == "pane-size-cancel":
491
+ self.dismiss(None)
492
+ elif event.button.id == "pane-size-apply":
493
+ self._apply()
494
+
495
+ def on_input_submitted(self, event: Input.Submitted) -> None:
496
+ self._apply()
497
+
498
+ def action_confirm(self) -> None:
499
+ self._apply()
500
+
501
+ def action_cancel(self) -> None:
502
+ self.dismiss(None)
503
+
504
+
505
+ # ─── JSON detail modal (Enter, after clicking a JSON line) ───────────────────
506
+
507
+ class JsonDetailModal(ModalScreen[None]):
508
+ """Full pretty-printed JSON for a detected JSON log line."""
509
+
510
+ DEFAULT_CSS = """
511
+ JsonDetailModal {
512
+ align: center middle;
513
+ }
514
+ #json-detail-dialog {
515
+ background: $surface;
516
+ border: thick $accent;
517
+ padding: 1 2;
518
+ width: 80%;
519
+ height: 80%;
520
+ }
521
+ #json-detail-title {
522
+ text-style: bold;
523
+ color: $accent;
524
+ margin-bottom: 1;
525
+ }
526
+ #json-detail-scroll {
527
+ height: 1fr;
528
+ border: tall $panel;
529
+ }
530
+ #json-detail-body {
531
+ width: auto;
532
+ padding: 0 1;
533
+ }
534
+ #json-detail-hint {
535
+ color: $text-muted;
536
+ margin-top: 1;
537
+ }
538
+ """
539
+
540
+ BINDINGS = [
541
+ Binding("escape", "close", "Close"),
542
+ Binding("enter", "close", "Close"),
543
+ ]
544
+
545
+ def __init__(self, pretty_json: str, **kwargs) -> None:
546
+ super().__init__(**kwargs)
547
+ self._pretty = pretty_json
548
+
549
+ def compose(self) -> ComposeResult:
550
+ with Vertical(id="json-detail-dialog"):
551
+ yield Static("JSON detail", id="json-detail-title")
552
+ with ScrollableContainer(id="json-detail-scroll"):
553
+ # markup=False — JSON is full of [ ] { } which Rich/Textual
554
+ # would otherwise try to parse as markup tags.
555
+ yield Static(self._pretty, id="json-detail-body", markup=False)
556
+ yield Static("[dim]Esc or Enter to close[/dim]", id="json-detail-hint", markup=True)
557
+
558
+ def action_close(self) -> None:
559
+ self.dismiss(None)
560
+
561
+
562
+ # ─── Monitor context modal (Enter, after clicking a monitor hit) ─────────────
563
+
564
+ class MonitorContextModal(ModalScreen[None]):
565
+ """
566
+ ± N lines of same-container context around a clicked monitor hit, like
567
+ `grep -C`. Starts small and grows/shrinks live via +/- — filtering the
568
+ container's lines out of the buffer already costs the same regardless of how
569
+ many end up shown, and the body scrolls, so there's no real reason to
570
+ hard-cap the window at a small fixed number.
571
+ """
572
+
573
+ DEFAULT_CONTEXT = 3
574
+ STEP = 5
575
+ MAX_CONTEXT = 500 # sanity cap, not a meaningful UX limit
576
+
577
+ DEFAULT_CSS = """
578
+ MonitorContextModal {
579
+ align: center middle;
580
+ }
581
+ #monitor-context-dialog {
582
+ background: $surface;
583
+ border: thick $accent;
584
+ padding: 1 2;
585
+ width: 90%;
586
+ height: 80%;
587
+ }
588
+ #monitor-context-title {
589
+ text-style: bold;
590
+ color: $accent;
591
+ margin-bottom: 1;
592
+ }
593
+ #monitor-context-scroll {
594
+ height: 1fr;
595
+ border: tall $panel;
596
+ }
597
+ #monitor-context-body {
598
+ width: auto;
599
+ padding: 0 1;
600
+ }
601
+ #monitor-context-hint {
602
+ color: $text-muted;
603
+ margin-top: 1;
604
+ }
605
+ """
606
+
607
+ BINDINGS = [
608
+ Binding("escape", "close", "Close"),
609
+ Binding("enter", "close", "Close"),
610
+ Binding("+", "grow", "More context", show=True),
611
+ Binding("=", "grow", "More context", show=False),
612
+ Binding("-", "shrink", "Less context", show=True),
613
+ ]
614
+
615
+ def __init__(self, container_lines: list[LogLine], idx: int, container_name: str, **kwargs) -> None:
616
+ """container_lines: every buffered line from this hit's container, in order.
617
+ idx: the hit's own position within container_lines."""
618
+ super().__init__(**kwargs)
619
+ self._container_lines = container_lines
620
+ self._idx = idx
621
+ self._container_name = container_name
622
+ self._context_n = self.DEFAULT_CONTEXT
623
+
624
+ def compose(self) -> ComposeResult:
625
+ with Vertical(id="monitor-context-dialog"):
626
+ # Scoped to one container — the title makes that explicit, since the
627
+ # lines shown are neighbors in that container's own stream, not
628
+ # whatever else was arriving around the same wall-clock moment.
629
+ yield Static(f"Monitor hit context — {self._container_name}", id="monitor-context-title")
630
+ with ScrollableContainer(id="monitor-context-scroll"):
631
+ yield Static(self._build_body(), id="monitor-context-body", markup=False)
632
+ yield Static(self._hint(), id="monitor-context-hint", markup=True)
633
+
634
+ def _build_body(self) -> Text:
635
+ start = max(0, self._idx - self._context_n)
636
+ end = self._idx + self._context_n + 1
637
+ body = Text()
638
+ for i, line in enumerate(self._container_lines[start:end]):
639
+ if i:
640
+ body.append("\n")
641
+ if start + i == self._idx:
642
+ body.append("▶ ", style="bold #ff8800")
643
+ body.append(line.content, style="bold white on #2a2a00")
644
+ else:
645
+ body.append(" ")
646
+ body.append(line.content, style="dim")
647
+ return body
648
+
649
+ def _hint(self) -> str:
650
+ return (
651
+ f"[dim]±{self._context_n} lines · +/- for more or less · "
652
+ "Esc or Enter to close[/dim]"
653
+ )
654
+
655
+ def _refresh(self) -> None:
656
+ self.query_one("#monitor-context-body", Static).update(self._build_body())
657
+ self.query_one("#monitor-context-hint", Static).update(self._hint())
658
+
659
+ def action_grow(self) -> None:
660
+ self._context_n = min(self._context_n + self.STEP, self.MAX_CONTEXT)
661
+ self._refresh()
662
+
663
+ def action_shrink(self) -> None:
664
+ self._context_n = max(self._context_n - self.STEP, 0)
665
+ self._refresh()
666
+
667
+ def action_close(self) -> None:
668
+ self.dismiss(None)
File without changes
@@ -0,0 +1,40 @@
1
+ """
2
+ docker-orb setup wizard — single-page form.
3
+ Returns a SessionConfig on completion or None if the user cancels.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from textual.app import App
8
+ from textual.binding import Binding
9
+
10
+ from .. import _scrollbar
11
+ from ..models import SessionConfig
12
+ from .screens import SinglePageWizard
13
+
14
+ _scrollbar.install()
15
+
16
+
17
+ class WizardApp(App[SessionConfig | None]):
18
+
19
+ TITLE = "docker-orb setup"
20
+ CSS_PATH = "wizard.tcss"
21
+
22
+ BINDINGS = [
23
+ Binding("ctrl+q", "quit", "Cancel", show=True),
24
+ ]
25
+
26
+ def __init__(self, initial_project: str = "default") -> None:
27
+ super().__init__()
28
+ self._initial_project = initial_project
29
+
30
+ def on_mount(self) -> None:
31
+ self.push_screen(
32
+ SinglePageWizard(initial_project=self._initial_project),
33
+ self._on_done,
34
+ )
35
+
36
+ def _on_done(self, config: SessionConfig | None) -> None:
37
+ self.exit(config)
38
+
39
+ def action_quit(self) -> None:
40
+ self.exit(None)