slab-cli 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {slab_cli-0.5.0 → slab_cli-0.7.0}/PKG-INFO +27 -6
  2. {slab_cli-0.5.0 → slab_cli-0.7.0}/README.md +26 -5
  3. {slab_cli-0.5.0 → slab_cli-0.7.0}/pyproject.toml +1 -1
  4. slab_cli-0.7.0/src/slab_cli/browse.py +386 -0
  5. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/breaks.py +5 -9
  6. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/catalog.py +29 -3
  7. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/collection.py +15 -2
  8. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/custom_sets.py +10 -4
  9. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/lots.py +5 -8
  10. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/registry.py +7 -1
  11. slab_cli-0.7.0/src/slab_cli/commands/update.py +124 -0
  12. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/display.py +219 -173
  13. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/help.py +6 -1
  14. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/main.py +38 -6
  15. slab_cli-0.7.0/src/slab_cli/updates.py +242 -0
  16. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/__init__.py +0 -0
  17. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/banner.py +0 -0
  18. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/client.py +0 -0
  19. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/__init__.py +0 -0
  20. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/export.py +0 -0
  21. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/pricing.py +0 -0
  22. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/commands/setup.py +0 -0
  23. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/config.py +0 -0
  24. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/context.py +0 -0
  25. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/flags.py +0 -0
  26. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/paging.py +0 -0
  27. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/picker.py +0 -0
  28. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/prompts.py +0 -0
  29. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/sources.py +0 -0
  30. {slab_cli-0.5.0 → slab_cli-0.7.0}/src/slab_cli/theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: slab-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal.
5
5
  Author: dev_jeb
6
6
  Requires-Dist: slab-schemas>=0.1.0
@@ -56,11 +56,32 @@ to see a group's verbs.
56
56
 
57
57
  ## Configuration reference
58
58
 
59
- | Env var | Purpose | Default |
60
- | --------------- | ------------------------------------------ | -------------------------------- |
61
- | `SLAB_API_KEY` | API key for authentication (**required**) | — |
62
- | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
63
- | `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
59
+ | Env var | Purpose | Default |
60
+ | ---------------------- | ------------------------------------------ | -------------------------------- |
61
+ | `SLAB_API_KEY` | API key for authentication (**required**) | — |
62
+ | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
63
+ | `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
64
+ | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
65
+
66
+ ## Staying up to date
67
+
68
+ Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
69
+ runs) whether there's a newer release, and if so prints one line after the output:
70
+
71
+ ```
72
+ ▍ slab-cli 0.7.0 is available — you have 0.6.0
73
+ Update when you like: slab update
74
+ ```
75
+
76
+ ```bash
77
+ slab --version # what you have
78
+ slab update check # ask PyPI right now; installs nothing
79
+ slab update # upgrade, after showing the command and asking
80
+ ```
81
+
82
+ `slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
83
+ `uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
84
+ yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
64
85
 
65
86
  ## What it depends on
66
87
 
@@ -44,11 +44,32 @@ to see a group's verbs.
44
44
 
45
45
  ## Configuration reference
46
46
 
47
- | Env var | Purpose | Default |
48
- | --------------- | ------------------------------------------ | -------------------------------- |
49
- | `SLAB_API_KEY` | API key for authentication (**required**) | — |
50
- | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
51
- | `SLAB_COLLECTOR`| Active collector UUID, reused per command | — |
47
+ | Env var | Purpose | Default |
48
+ | ---------------------- | ------------------------------------------ | -------------------------------- |
49
+ | `SLAB_API_KEY` | API key for authentication (**required**) | — |
50
+ | `SLAB_API_URL` | Base URL of the slab API | `https://api.slab.dev-jeb.com` |
51
+ | `SLAB_COLLECTOR` | Active collector UUID, reused per command | — |
52
+ | `SLAB_NO_UPDATE_CHECK` | Set to `1` to silence update checks | — |
53
+
54
+ ## Staying up to date
55
+
56
+ Nothing updates itself. At most once a day the CLI asks PyPI (in the background, while your command
57
+ runs) whether there's a newer release, and if so prints one line after the output:
58
+
59
+ ```
60
+ ▍ slab-cli 0.7.0 is available — you have 0.6.0
61
+ Update when you like: slab update
62
+ ```
63
+
64
+ ```bash
65
+ slab --version # what you have
66
+ slab update check # ask PyPI right now; installs nothing
67
+ slab update # upgrade, after showing the command and asking
68
+ ```
69
+
70
+ `slab update` runs the upgrade that matches how you installed slab — `pipx upgrade`,
71
+ `uv tool upgrade`, or `pip install --upgrade` — and prints it first, so you can decline and run it
72
+ yourself. To turn the whole thing off: `export SLAB_NO_UPDATE_CHECK=1`.
52
73
 
53
74
  ## What it depends on
54
75
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "slab-cli"
3
- version = "0.5.0"
3
+ version = "0.7.0"
4
4
  description = "CLI for the slab trading-card API — catalog, collect, and track your cards from the terminal."
5
5
  readme = "README.md"
6
6
  authors = [{name = "dev_jeb"}]
@@ -0,0 +1,386 @@
1
+ """An interactive, scrollable table — the browsable counterpart to a static `slab_table`.
2
+
3
+ `browse(columns, rows, ...)` shows the same columns and cells a static table would, but as a
4
+ keyboard-driven viewport: ↑/↓ (or j/k) move the highlight, PgUp/PgDn jump, `/` type-to-filters
5
+ across every column, and q leaves — printing the plain table on the way out so the data
6
+ survives in scrollback. Enter drills down: with a `detail` callback the highlighted row's
7
+ detail view REPLACES the list as a scrollable page (Esc/q climbs back up to the list, same row
8
+ highlighted); without one, Enter simply returns the row's item to the caller. Anywhere output
9
+ isn't an interactive terminal (a pipe, CI, redirection, a platform without termios) it
10
+ degrades to the static table, so `slab ... | grep` keeps working unchanged.
11
+
12
+ This is THE tabular display for result lists: a command builds `(item, cells)` rows once and
13
+ the same spec drives the interactive view, the static fallback, and the exit receipt.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ from typing import Any, Callable, Sequence
21
+
22
+ from rich.console import Group
23
+ from rich.live import Live
24
+ from rich.text import Text
25
+
26
+ from .theme import console, heading, slab_table
27
+
28
+ # One row: the domain object Enter should act on, plus its cell markup strings (one per column).
29
+ Row = tuple[Any, Sequence[str]]
30
+
31
+ # The highlight bar — a slate a step brighter than the card border, so the selected row reads as
32
+ # lit without fighting the cells' own foreground colors (markup styles set color only, never bg).
33
+ _SELECTED_BG = "on #223052"
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Static rendering — the fallback, and the receipt printed when a browse ends
38
+ # ---------------------------------------------------------------------------
39
+
40
+ def print_table(
41
+ columns: list[tuple[str, dict]],
42
+ rows: list[Row],
43
+ *,
44
+ title: str,
45
+ note: str | None = None,
46
+ footer: str | None = None,
47
+ ) -> None:
48
+ """The classic non-interactive rendering: heading + slab_table + optional dim footer."""
49
+ table = slab_table(columns)
50
+ for _item, cells in rows:
51
+ table.add_row(*(str(c) for c in cells))
52
+ console.print(heading(title, note))
53
+ console.print(table)
54
+ if footer:
55
+ console.print(f" [slab.dim]{footer}[/]")
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Pure helpers (unit-tested offline)
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def row_plain(cells: Sequence[str]) -> str:
63
+ """A row's cells flattened to lowercase plain text — what `/` filtering matches against."""
64
+ return Text.from_markup(" ".join(str(c) for c in cells)).plain.lower()
65
+
66
+
67
+ def matches(query: str, plain: str) -> bool:
68
+ """Every whitespace-separated token of the query must appear somewhere in the row."""
69
+ return all(tok in plain for tok in query.lower().split())
70
+
71
+
72
+ def scroll_top(sel: int, top: int, viewport: int, n: int) -> int:
73
+ """The first visible row index keeping `sel` inside a `viewport`-row window over `n` rows."""
74
+ if n <= viewport:
75
+ return 0
76
+ if sel < top:
77
+ top = sel
78
+ elif sel >= top + viewport:
79
+ top = sel - viewport + 1
80
+ return max(0, min(top, n - viewport))
81
+
82
+
83
+ def pager_top(top: int, viewport: int, n: int) -> int:
84
+ """Clamp a pager's first visible line so the window never runs past the content."""
85
+ return max(0, min(top, n - viewport))
86
+
87
+
88
+ _SEQUENCES = {
89
+ b"\x1b[A": "up", b"\x1b[B": "down",
90
+ b"\x1bOA": "up", b"\x1bOB": "down", # application cursor mode
91
+ b"\x1b[5~": "pgup", b"\x1b[6~": "pgdn",
92
+ b"\x1b[H": "home", b"\x1b[F": "end",
93
+ b"\x1b[1~": "home", b"\x1b[4~": "end",
94
+ b"\x1b[D": "left", b"\x1bOD": "left", # ← also climbs back up a level
95
+ }
96
+
97
+
98
+ def split_keys(data: bytes) -> list[str]:
99
+ """A raw input burst -> ordered key names ('up', 'enter', 'esc', …) and printable chars.
100
+
101
+ Terminals deliver fast typing, pastes, and escape sequences as multi-byte chunks; parsing
102
+ the whole burst (instead of assuming one read = one key) is what keeps a pasted filter
103
+ query intact and an arrow key from being mistaken for its component bytes."""
104
+ keys: list[str] = []
105
+ i = 0
106
+ while i < len(data):
107
+ if data[i:i + 1] == b"\x1b":
108
+ for seq, name in _SEQUENCES.items():
109
+ if data.startswith(seq, i):
110
+ keys.append(name)
111
+ i += len(seq)
112
+ break
113
+ else:
114
+ if data[i + 1:i + 2] in (b"[", b"O"):
115
+ # An escape sequence we don't drive anything with: swallow through its
116
+ # final byte (0x40–0x7e) so its payload doesn't leak in as typed text.
117
+ j = i + 2
118
+ while j < len(data) and not 0x40 <= data[j] <= 0x7E:
119
+ j += 1
120
+ i = j + 1
121
+ else:
122
+ i += 1 # bare Escape
123
+ keys.append("esc")
124
+ continue
125
+ j = data.find(b"\x1b", i)
126
+ run = data[i:] if j == -1 else data[i:j]
127
+ i = len(data) if j == -1 else j
128
+ for ch in run.decode(errors="ignore"):
129
+ if ch in ("\r", "\n"):
130
+ keys.append("enter")
131
+ elif ch in ("\x7f", "\x08"):
132
+ keys.append("backspace")
133
+ elif ch == "\x03":
134
+ keys.append("quit") # Ctrl-C when ISIG doesn't get there first
135
+ elif ch.isprintable():
136
+ keys.append(ch)
137
+ return keys
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # The interactive machinery
142
+ # ---------------------------------------------------------------------------
143
+
144
+ def _interactive_ok() -> bool:
145
+ """True when we can actually run a keyboard loop: a real terminal on a termios platform."""
146
+ if not (console.is_terminal and sys.stdin.isatty()):
147
+ return False
148
+ try:
149
+ import termios # noqa: F401 (Unix-only; absent on Windows)
150
+ import tty # noqa: F401
151
+ except ImportError:
152
+ return False
153
+ return True
154
+
155
+
156
+ def _read_keys(fd: int) -> list[str]:
157
+ return split_keys(os.read(fd, 64))
158
+
159
+
160
+ def _frame(
161
+ columns: list[tuple[str, dict]],
162
+ view: list[Row],
163
+ sel: int,
164
+ top: int,
165
+ viewport: int,
166
+ *,
167
+ title: str,
168
+ note: str | None,
169
+ hint: str,
170
+ total: int,
171
+ query: str,
172
+ typing: bool,
173
+ ) -> Group:
174
+ """One rendered list frame: heading + the visible window of rows + the status footer."""
175
+ table = slab_table(columns)
176
+ for i in range(top, min(top + viewport, len(view))):
177
+ _item, cells = view[i]
178
+ first, rest = str(cells[0]), [str(c) for c in cells[1:]]
179
+ if i == sel:
180
+ table.add_row(f"[slab.foil]▸[/] {first}", *rest, style=_SELECTED_BG)
181
+ else:
182
+ table.add_row(f" {first}", *rest)
183
+
184
+ pos = f"{sel + 1}/{len(view)}" if view else "0 matches"
185
+ if query and len(view) != total:
186
+ pos += f" [slab.muted](of {total})[/]"
187
+ if typing:
188
+ status = (f" [slab.accent]/{query}▌[/][slab.dim] {pos} · ⏎ done · esc clear[/]")
189
+ elif query:
190
+ status = (f" [slab.dim]{pos} · [/][slab.accent]/{query}[/]"
191
+ f"[slab.dim] · ↑↓ move · ⏎ {hint} · esc clear · q quit[/]")
192
+ else:
193
+ status = f" [slab.dim]{pos} · ↑↓ move · ⏎ {hint} · / filter · q quit[/]"
194
+
195
+ return Group(heading(title, note), table, Text.from_markup(status))
196
+
197
+
198
+ def _run_list(
199
+ fd: int,
200
+ columns: list[tuple[str, dict]],
201
+ rows: list[Row],
202
+ *,
203
+ title: str,
204
+ note: str | None,
205
+ hint: str,
206
+ start: int,
207
+ ) -> tuple[Any | None, int]:
208
+ """The list screen. Returns (chosen item, its index in `rows`) on Enter, or (None, last
209
+ highlighted index) on quit — the index lets a drill-down return to the same row."""
210
+ plains = [row_plain(cells) for _item, cells in rows]
211
+ query, typing = "", False
212
+ sel = min(max(start, 0), len(rows) - 1)
213
+ top = 0
214
+ pending: list[str] = []
215
+
216
+ def row_index(view_row: Row) -> int:
217
+ return next(i for i, r in enumerate(rows) if r is view_row)
218
+
219
+ with Live(console=console, transient=True, auto_refresh=False) as live:
220
+ while True:
221
+ view = ([r for r, p in zip(rows, plains) if matches(query, p)]
222
+ if query else rows)
223
+ if sel >= len(view):
224
+ sel = max(0, len(view) - 1)
225
+ # Leave room for the shell prompt, heading, table chrome, and the footer.
226
+ viewport = max(3, min(len(view) or 1, console.size.height - 8))
227
+ top = scroll_top(sel, top, viewport, len(view))
228
+ live.update(
229
+ _frame(columns, view, sel, top, viewport, title=title, note=note,
230
+ hint=hint, total=len(rows), query=query, typing=typing),
231
+ refresh=True,
232
+ )
233
+
234
+ if not pending:
235
+ pending = _read_keys(fd)
236
+ if not pending:
237
+ continue
238
+ key = pending.pop(0)
239
+
240
+ if key == "up" or (not typing and key == "k"):
241
+ sel = max(0, sel - 1)
242
+ elif key == "down" or (not typing and key == "j"):
243
+ sel = min(len(view) - 1, sel + 1) if view else 0
244
+ elif key == "pgup":
245
+ sel = max(0, sel - viewport)
246
+ elif key == "pgdn":
247
+ sel = min(len(view) - 1, sel + viewport) if view else 0
248
+ elif key == "home" or (not typing and key == "g"):
249
+ sel = 0
250
+ elif key == "end" or (not typing and key == "G"):
251
+ sel = max(0, len(view) - 1)
252
+ elif key == "enter":
253
+ if typing:
254
+ typing = False # keep the filter, back to moving
255
+ elif view:
256
+ return view[sel][0], row_index(view[sel])
257
+ elif key == "esc":
258
+ if typing or query:
259
+ typing, query = False, ""
260
+ else:
261
+ return None, sel
262
+ elif key == "quit" or (not typing and key == "q"):
263
+ return None, (row_index(view[sel]) if view else 0)
264
+ elif typing:
265
+ if key == "backspace":
266
+ if query:
267
+ query = query[:-1]
268
+ else:
269
+ typing = False
270
+ elif len(key) == 1 and key.isprintable():
271
+ query += key
272
+ sel = 0 # a narrowed list re-anchors at its best (first) match
273
+ elif key == "/":
274
+ typing = True
275
+
276
+
277
+ def _run_pager(fd: int, lines: list[Text], *, back_hint: str) -> None:
278
+ """The detail screen: a captured view as a scrollable page. Esc/q/←/backspace climbs back
279
+ up to the list; ↑↓, PgUp/PgDn, and space scroll when the content is taller than the
280
+ terminal. Returns when the user leaves."""
281
+ top = 0
282
+ pending: list[str] = []
283
+ with Live(console=console, transient=True, auto_refresh=False) as live:
284
+ while True:
285
+ viewport = max(3, console.size.height - 3)
286
+ top = pager_top(top, viewport, len(lines))
287
+ window: list[Any] = list(lines[top:top + viewport])
288
+ if len(lines) > viewport:
289
+ pos = f"{top + 1}–{min(top + viewport, len(lines))}/{len(lines)}"
290
+ status = f" [slab.dim]{pos} · ↑↓ scroll · esc {back_hint}[/]"
291
+ else:
292
+ status = f" [slab.dim]esc {back_hint}[/]"
293
+ window.append(Text.from_markup(status))
294
+ live.update(Group(*window), refresh=True)
295
+
296
+ if not pending:
297
+ pending = _read_keys(fd)
298
+ if not pending:
299
+ continue
300
+ key = pending.pop(0)
301
+
302
+ if key in ("up", "k"):
303
+ top -= 1
304
+ elif key in ("down", "j", "enter"):
305
+ top += 1
306
+ elif key in ("pgup",):
307
+ top -= viewport
308
+ elif key in ("pgdn", " "):
309
+ top += viewport
310
+ elif key in ("home", "g"):
311
+ top = 0
312
+ elif key in ("end", "G"):
313
+ top = len(lines)
314
+ elif key in ("esc", "q", "left", "backspace", "quit"):
315
+ return
316
+
317
+
318
+ def _capture_lines(render: Callable[[], None]) -> list[Text]:
319
+ """Run a print-to-console renderer and capture its output as styled lines for the pager."""
320
+ with console.capture() as cap:
321
+ render()
322
+ lines = [Text.from_ansi(line) for line in cap.get().splitlines()]
323
+ while lines and not lines[-1].plain.strip():
324
+ lines.pop()
325
+ while lines and not lines[0].plain.strip():
326
+ lines.pop(0)
327
+ return lines or [Text.from_markup("[slab.dim]Nothing to show.[/]")]
328
+
329
+
330
+ def browse(
331
+ columns: list[tuple[str, dict]],
332
+ rows: list[Row],
333
+ *,
334
+ title: str,
335
+ note: str | None = None,
336
+ hint: str = "open",
337
+ footer: str | None = None,
338
+ start: int = 0,
339
+ detail: Callable[[Any], None] | None = None,
340
+ ) -> Any | None:
341
+ """Browse `rows` interactively. `columns` is the same `(header, kwargs)` spec `slab_table`
342
+ takes; each row is `(item, cells)` with one markup string per column. `hint` names what
343
+ Enter does in the footer (e.g. "market view"); `start` pre-selects a row.
344
+
345
+ With a `detail` callback, Enter drills down: `detail(item)` runs (it may fetch and must
346
+ print to the console), its output replaces the list as a scrollable page, and leaving the
347
+ page returns to the list on the same row — the loop lives here, and browse returns None
348
+ when the user finally quits. Without `detail`, Enter returns the chosen item directly.
349
+
350
+ Quitting from the list prints the plain table (with `footer`) as a scrollback receipt. On
351
+ a non-interactive stdout/stdin the static table is printed instead and None returned.
352
+ """
353
+ if not rows:
354
+ return None
355
+ if not _interactive_ok():
356
+ print_table(columns, rows, title=title, note=note, footer=footer)
357
+ return None
358
+
359
+ import termios
360
+ import tty
361
+
362
+ fd = sys.stdin.fileno()
363
+ saved = termios.tcgetattr(fd)
364
+ picked: Any | None = None
365
+ sel = start
366
+ try:
367
+ tty.setcbreak(fd)
368
+ while True:
369
+ item, sel = _run_list(fd, columns, rows, title=title, note=note,
370
+ hint=hint, start=sel)
371
+ if item is None:
372
+ break
373
+ if detail is None:
374
+ picked = item
375
+ break
376
+ _run_pager(fd, _capture_lines(lambda: detail(item)), back_hint="back to list")
377
+ except KeyboardInterrupt:
378
+ pass
379
+ finally:
380
+ termios.tcsetattr(fd, termios.TCSADRAIN, saved)
381
+
382
+ if picked is not None:
383
+ return picked
384
+ # Quit: leave the classic table behind so the results live on in scrollback.
385
+ print_table(columns, rows, title=title, note=note, footer=footer)
386
+ return None
@@ -10,7 +10,7 @@ from slab_schemas.enums import BreakType
10
10
 
11
11
  from ..client import SlabClient
12
12
  from ..context import ctx
13
- from ..display import console, print_breaks, render_break_pulls
13
+ from ..display import browse_breaks, console, render_break_pulls
14
14
  from ..prompts import (
15
15
  ask_date,
16
16
  ask_decimal,
@@ -85,15 +85,11 @@ def create_break_interactive(client: SlabClient, collector: str) -> BreakOut | N
85
85
 
86
86
 
87
87
  def cmd_break_list(args: list[str]) -> None:
88
- """`slab break list` — your breaks, then pick one to see what it delivered (value vs cost,
89
- the top pulls, the rarest pulls). Skippable — the list stays on screen either way."""
88
+ """`slab break list` — your breaks as a scrollable list; Enter drills into what one
89
+ delivered (value vs cost, the top pulls, the rarest pulls), Esc climbs back, q leaves the
90
+ plain table behind. Piped output prints the plain table."""
90
91
  result = ctx.client.search_breaks(ctx.collector, BreakSearchQuery(limit=25))
91
- print_breaks(result)
92
- if not result.items:
93
- return
94
- choice = select_break(result, allow_new=False)
95
- if isinstance(choice, BreakOut):
96
- _show_break_pulls(choice)
92
+ browse_breaks(result, detail=_show_break_pulls)
97
93
 
98
94
 
99
95
  def _show_break_pulls(brk: BreakOut) -> None:
@@ -7,7 +7,14 @@ import re
7
7
  from slab_schemas.cards import CardSearchQuery, SetSearchQuery
8
8
 
9
9
  from ..context import ctx
10
- from ..display import console, print_cards, print_sets, render_community_board
10
+ from ..display import (
11
+ browse_cards,
12
+ browse_sets,
13
+ console,
14
+ render_card_market,
15
+ render_community_board,
16
+ render_set_market,
17
+ )
11
18
  from ..flags import CARD_SEARCH_FLAGS, SET_SEARCH_FLAGS, parse_flags
12
19
  from ..picker import pick_card
13
20
  from ..prompts import ask_text
@@ -31,6 +38,9 @@ def cmd_search(args: list[str]) -> None:
31
38
  Every flag combines with AND; repeat an any-of flag (--team, --brand, --finish…) to widen it.
32
39
 
33
40
  Sort keys: year, card_number, numbered, subject, brand, set (prefix - for descending).
41
+
42
+ In a terminal the results are a scrollable list — Enter drills into the highlighted
43
+ card's market view, Esc climbs back. Piped output prints the plain table.
34
44
  """
35
45
  if not args:
36
46
  args = [ask_text("Search cards:")]
@@ -38,7 +48,11 @@ def cmd_search(args: list[str]) -> None:
38
48
  values, q = parse_flags(args, CARD_SEARCH_FLAGS)
39
49
  values.setdefault("limit", 25)
40
50
  result = ctx.client.search_cards(CardSearchQuery(q=q, **values))
41
- print_cards(result)
51
+
52
+ def market_view(c) -> None:
53
+ render_card_market(ctx.client.get_card_market(c.uuid))
54
+
55
+ browse_cards(result, detail=market_view)
42
56
 
43
57
 
44
58
  cmd_search.flags = CARD_SEARCH_FLAGS
@@ -47,6 +61,12 @@ cmd_search.flags = CARD_SEARCH_FLAGS
47
61
  def cmd_sets(args: list[str]) -> None:
48
62
  """Browse sets / products, optionally narrowed by brand, year, or sport.
49
63
 
64
+ In a terminal the results are a scrollable list: ↑↓ (or j/k) move, / type-to-filters
65
+ across every column, Enter drills into the highlighted set's market view (sealed FMV +
66
+ top cards) as its own page — Esc climbs back up to the list, same row — and q quits,
67
+ leaving the plain table behind. Piped or redirected output prints the plain table
68
+ directly, so scripting is unchanged.
69
+
50
70
  Examples:
51
71
  slab set search "sp authentic"
52
72
  slab set search --brand "Upper Deck" --year 2025
@@ -54,7 +74,13 @@ def cmd_sets(args: list[str]) -> None:
54
74
  values, q = parse_flags(args, SET_SEARCH_FLAGS)
55
75
  values.setdefault("limit", 25)
56
76
  result = ctx.client.search_sets(SetSearchQuery(q=q, **values))
57
- print_sets(result)
77
+
78
+ def market_view(s) -> None:
79
+ sealed = ctx.client.get_set_sealed(s.uuid)
80
+ top = ctx.client.get_set_top_cards(s.uuid, limit=10)
81
+ render_set_market(s, sealed, top)
82
+
83
+ browse_sets(result, detail=market_view)
58
84
 
59
85
 
60
86
  cmd_sets.flags = SET_SEARCH_FLAGS
@@ -22,10 +22,12 @@ from ..flags import COLLECTION_SEARCH_FLAGS, parse_flags
22
22
  from ..paging import fetch_all_pages
23
23
  from ..picker import pick_card
24
24
  from ..display import (
25
+ browse_collection,
25
26
  console,
26
27
  format_card_summary,
27
28
  format_grade_delta,
28
- print_collection,
29
+ print_collection_summary,
30
+ render_card_market,
29
31
  render_dashboard,
30
32
  )
31
33
  from ..prompts import (
@@ -605,6 +607,9 @@ def cmd_search(args: list[str]) -> None:
605
607
 
606
608
  Sort keys: acquired_date, acquisition_cost, numbered, year, card_number, brand, set,
607
609
  fmv, unrealized, roi (prefix - for descending).
610
+
611
+ In a terminal the results are a scrollable list — Enter drills into the highlighted
612
+ copy's market view, Esc climbs back. Piped output prints the plain table.
608
613
  """
609
614
  if not args:
610
615
  total = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(limit=1)).total
@@ -616,7 +621,15 @@ def cmd_search(args: list[str]) -> None:
616
621
  values, q = parse_flags(args, COLLECTION_SEARCH_FLAGS)
617
622
  values.setdefault("limit", 25)
618
623
  result = ctx.client.search_collection(ctx.collector, CollectionSearchQuery(q=q, **values))
619
- print_collection(result)
624
+
625
+ def market_view(cp) -> None:
626
+ if cp.card is None:
627
+ console.print("[slab.dim]This copy isn't linked to a catalog card.[/]")
628
+ return
629
+ render_card_market(ctx.client.get_card_market(cp.card.uuid))
630
+
631
+ browse_collection(result, detail=market_view)
632
+ print_collection_summary(result)
620
633
 
621
634
 
622
635
  cmd_search.flags = COLLECTION_SEARCH_FLAGS
@@ -16,10 +16,10 @@ from slab_schemas.enums import CustomSetType, MatchMode, Visibility
16
16
  from ..client import SlabClient
17
17
  from ..context import ctx
18
18
  from ..display import (
19
+ browse_custom_sets,
19
20
  console,
20
21
  format_card_summary,
21
22
  print_custom_set_detail,
22
- print_custom_sets,
23
23
  )
24
24
  from ..picker import search_and_pick_subject
25
25
  from ..prompts import (
@@ -348,10 +348,16 @@ def cmd_chase_edit(args: list[str]) -> None:
348
348
  # slab chase list — your custom sets
349
349
  # ---------------------------------------------------------------------------
350
350
 
351
+ def _chase_detail(s) -> None:
352
+ """The drill-in for a browsed chase set: its full card list + completion, owner-aware."""
353
+ print_custom_set_detail(ctx.client.get_custom_set(s.uuid, ctx.collector))
354
+
355
+
351
356
  def cmd_chase_list(args: list[str]) -> None:
352
- """List your custom sets (created + subscribed)."""
357
+ """List your custom sets (created + subscribed) as a scrollable list; Enter drills into a
358
+ set's cards + completion, Esc climbs back."""
353
359
  sets = ctx.client.collector_custom_sets(ctx.collector)
354
- print_custom_sets(sets)
360
+ browse_custom_sets(sets, detail=_chase_detail)
355
361
 
356
362
 
357
363
  # ---------------------------------------------------------------------------
@@ -375,7 +381,7 @@ def cmd_chase_find(args: list[str]) -> None:
375
381
  if not sets:
376
382
  console.print("[yellow]No matching sets found.[/yellow]")
377
383
  return
378
- print_custom_sets(sets)
384
+ browse_custom_sets(sets, detail=_chase_detail)
379
385
  console.print(
380
386
  "[dim]`slab chase subscribe` to track one · `slab chase view <uuid>` to see its cards.[/dim]"
381
387
  )