lql-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 (35) hide show
  1. {lql_cli-0.5.0 → lql_cli-0.7.0}/PKG-INFO +15 -2
  2. {lql_cli-0.5.0 → lql_cli-0.7.0}/README.md +14 -1
  3. {lql_cli-0.5.0 → lql_cli-0.7.0}/pyproject.toml +1 -1
  4. lql_cli-0.7.0/src/lql/__init__.py +1 -0
  5. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/instructions.py +17 -3
  6. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/preview.py +93 -7
  7. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/tui.py +87 -15
  8. {lql_cli-0.5.0 → lql_cli-0.7.0}/uv.lock +1 -1
  9. lql_cli-0.5.0/src/lql/__init__.py +0 -1
  10. {lql_cli-0.5.0 → lql_cli-0.7.0}/.claude/settings.local.json +0 -0
  11. {lql_cli-0.5.0 → lql_cli-0.7.0}/.gitignore +0 -0
  12. {lql_cli-0.5.0 → lql_cli-0.7.0}/examples/agent-traces.jsonl +0 -0
  13. {lql_cli-0.5.0 → lql_cli-0.7.0}/package-lock.json +0 -0
  14. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/_group.py +0 -0
  15. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/_opts.py +0 -0
  16. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/api.py +0 -0
  17. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/cli.py +0 -0
  18. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/__init__.py +0 -0
  19. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/annotations.py +0 -0
  20. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/auth.py +0 -0
  21. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/buckets.py +0 -0
  22. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/datasets.py +0 -0
  23. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/edits.py +0 -0
  24. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/evals.py +0 -0
  25. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/highlights.py +0 -0
  26. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/issues.py +0 -0
  27. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/reports.py +0 -0
  28. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/skills.py +0 -0
  29. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/spec.py +0 -0
  30. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/update.py +0 -0
  31. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/workspaces.py +0 -0
  32. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/config.py +0 -0
  33. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/output.py +0 -0
  34. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/sessions.py +0 -0
  35. {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/util.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lql-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: lql — CLI for the Liquid DataViewer platform
5
5
  Project-URL: Homepage, https://github.com/Liquid4All/lql
6
6
  Author: Liquid AI
@@ -166,11 +166,23 @@ lql preview <file.jsonl|file.json> Local file: each line/object is a row
166
166
  lql preview <dataset-id> Platform dataset (fetched & paged lazily)
167
167
  lql preview <org/name> --hf HuggingFace repo: sync to DataViewer, then view
168
168
  lql preview <src> -c <field> Force field(s) as conversations (repeatable)
169
+ lql preview <src> -f "col=value" Filter rows (repeatable, AND); local & platform
169
170
  lql preview <src> -n <N> Page size when paging a platform dataset
170
171
  lql preview <src> --offset N Start at row index N
171
172
  lql preview <src> --title "<title>" Title shown in the viewer header
172
173
  ```
173
174
 
175
+ **Filtering (`--filter`/`-f`).** Show only matching rows — works on local files and
176
+ platform datasets (platform filtering runs server-side). Repeatable; filters AND
177
+ together; string match is case-insensitive. Operators: `=`, `!=`, `~` (contains),
178
+ `>`, `<`, `>=`, `<=`.
179
+
180
+ ```
181
+ lql preview <dataset-id> -f "domain=telecom"
182
+ lql preview data.jsonl -f "reward>=0.8" -f "split=test" # both must hold
183
+ lql preview <dataset-id> -f "model~lfm" # contains
184
+ ```
185
+
174
186
  **HuggingFace datasets (`--hf`).** `lql preview org/name --hf` syncs the repo
175
187
  into a DataViewer workspace, then opens it. You pick the target workspace from
176
188
  an interactive list (or pass `--workspace <id>`; `--split` defaults to `train`).
@@ -189,10 +201,11 @@ and **`p` plays** the current sample's audio via the system player (`afplay`/
189
201
  `open`). Images render inline in pager mode (one sample at a time); scroll mode
190
202
  shows placeholders to avoid decoding the whole buffer.
191
203
 
192
- **Navigation** — two modes, toggle with `m` / `Tab`:
204
+ **Navigation** — two modes, toggle with `m`:
193
205
 
194
206
  - **pager** (default): one sample at a time · `←/→` or `n`/`b` switch samples · `↑/↓`/`j`/`k`/PgUp-Dn scroll
195
207
  - **scroll**: all samples in one buffer · `n`/`b` jump between samples · arrows scroll
208
+ - **copy**: `Tab`/`Shift+Tab` move a highlight between blocks · `c` copies the focused message/field · `Y` copies the whole sample as JSON (via OSC 52 — reaches your **local** clipboard over SSH where the terminal supports it, e.g. Ghostty/iTerm2; not macOS Terminal)
196
209
  - `p` play audio · `q` quits
197
210
 
198
211
  ```
@@ -150,11 +150,23 @@ lql preview <file.jsonl|file.json> Local file: each line/object is a row
150
150
  lql preview <dataset-id> Platform dataset (fetched & paged lazily)
151
151
  lql preview <org/name> --hf HuggingFace repo: sync to DataViewer, then view
152
152
  lql preview <src> -c <field> Force field(s) as conversations (repeatable)
153
+ lql preview <src> -f "col=value" Filter rows (repeatable, AND); local & platform
153
154
  lql preview <src> -n <N> Page size when paging a platform dataset
154
155
  lql preview <src> --offset N Start at row index N
155
156
  lql preview <src> --title "<title>" Title shown in the viewer header
156
157
  ```
157
158
 
159
+ **Filtering (`--filter`/`-f`).** Show only matching rows — works on local files and
160
+ platform datasets (platform filtering runs server-side). Repeatable; filters AND
161
+ together; string match is case-insensitive. Operators: `=`, `!=`, `~` (contains),
162
+ `>`, `<`, `>=`, `<=`.
163
+
164
+ ```
165
+ lql preview <dataset-id> -f "domain=telecom"
166
+ lql preview data.jsonl -f "reward>=0.8" -f "split=test" # both must hold
167
+ lql preview <dataset-id> -f "model~lfm" # contains
168
+ ```
169
+
158
170
  **HuggingFace datasets (`--hf`).** `lql preview org/name --hf` syncs the repo
159
171
  into a DataViewer workspace, then opens it. You pick the target workspace from
160
172
  an interactive list (or pass `--workspace <id>`; `--split` defaults to `train`).
@@ -173,10 +185,11 @@ and **`p` plays** the current sample's audio via the system player (`afplay`/
173
185
  `open`). Images render inline in pager mode (one sample at a time); scroll mode
174
186
  shows placeholders to avoid decoding the whole buffer.
175
187
 
176
- **Navigation** — two modes, toggle with `m` / `Tab`:
188
+ **Navigation** — two modes, toggle with `m`:
177
189
 
178
190
  - **pager** (default): one sample at a time · `←/→` or `n`/`b` switch samples · `↑/↓`/`j`/`k`/PgUp-Dn scroll
179
191
  - **scroll**: all samples in one buffer · `n`/`b` jump between samples · arrows scroll
192
+ - **copy**: `Tab`/`Shift+Tab` move a highlight between blocks · `c` copies the focused message/field · `Y` copies the whole sample as JSON (via OSC 52 — reaches your **local** clipboard over SSH where the terminal supports it, e.g. Ghostty/iTerm2; not macOS Terminal)
180
193
  - `p` play audio · `q` quits
181
194
 
182
195
  ```
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "lql-cli"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "lql — CLI for the Liquid DataViewer platform"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -100,10 +100,19 @@ DataViewer workspace (you pick one interactively, or pass --workspace <id>;
100
100
  --split defaults to train) and reused on later previews (dedup by repo+split).
101
101
 
102
102
  Options: -c/--column (field(s) to treat as conversations; default auto-detect,
103
- repeatable), -n/--limit (page size when paging a platform dataset), --offset
104
- (start row index), --title, --hf, --split, --workspace, --profile, --api-url.
103
+ repeatable), -f/--filter (filter rows; see below), -n/--limit (page size when
104
+ paging a platform dataset), --offset (start row index), --title, --hf, --split,
105
+ --workspace, --profile, --api-url.
105
106
 
106
- Navigation: two modes toggled with m/Tabpager (one sample at a time; ←/→ or
107
+ Filtering: -f/--filter "col<op>value" shows only matching rows works on local
108
+ files and platform datasets (server-side for platform). Repeatable; filters AND
109
+ together; string compare is case-insensitive. Operators: = (eq), != (ne),
110
+ ~ (contains), >, <, >=, <=.
111
+
112
+ lql preview <dataset-id> -f "domain=telecom" -f "reward>=0.8"
113
+ lql preview data.jsonl -f "model~lfm"
114
+
115
+ Navigation: two modes toggled with m — pager (one sample at a time; ←/→ or
107
116
  n/b switch samples, ↑/↓/j/k scroll) and scroll (all samples; n/b jump between
108
117
  them). q quits. Works over plain SSH with no browser or port-forward.
109
118
 
@@ -112,6 +121,11 @@ Sixel; placeholder otherwise) for both multimodal image segments and image-mode
112
121
  columns; audio shows a ♪ line and `p` plays the current sample's clip via the
113
122
  system player (afplay/open).
114
123
 
124
+ Copy: Tab/Shift+Tab move a highlight between blocks, `c` copies the focused
125
+ message/field, `Y` copies the whole sample as JSON — via OSC 52, reaching the
126
+ local clipboard over SSH where the terminal supports it (e.g. Ghostty/iTerm2;
127
+ not macOS Terminal).
128
+
115
129
  ## Evals
116
130
 
117
131
  Eval datasets (evaluation-run output: each row a sample with a model 'response'
@@ -759,6 +759,67 @@ def _choose_workspace(client: ApiClient, tui_mod) -> Optional[str]:
759
759
  return choice
760
760
 
761
761
 
762
+ # --------------------------------------------------------------------------
763
+ # Row filtering (--filter "col<op>value")
764
+ # --------------------------------------------------------------------------
765
+
766
+ # Maps each CLI symbol to the platform filter API's operator name (the same
767
+ # names work server-side and locally). _parse_filters picks the earliest operator
768
+ # (longest on a tie), so list order doesn't affect correctness.
769
+ _FILTER_OPS = [(">=", "gte"), ("<=", "lte"), ("!=", "ne"), ("~", "contains"), ("=", "eq"), (">", "gt"), ("<", "lt")]
770
+ _NUMERIC_OPS = {"gt": lambda c, v: c > v, "lt": lambda c, v: c < v, "gte": lambda c, v: c >= v, "lte": lambda c, v: c <= v}
771
+
772
+
773
+ def _parse_filters(specs: Optional[List[str]]) -> List[tuple]:
774
+ """Parse ['col=value', 'reward>=0.5', 'name~kod'] → [(col, op, value), ...].
775
+
776
+ Splits on the EARLIEST operator (longest on a tie, so 'reward>=5' is gte not
777
+ gt), keeping operator chars in the value intact (e.g. 'q=a>b' → col 'q', value
778
+ 'a>b'). Rejects an empty column or value."""
779
+ out: List[tuple] = []
780
+ for spec in specs or []:
781
+ chosen = None # (index, symbol, op_name)
782
+ for sym, op in _FILTER_OPS:
783
+ i = spec.find(sym)
784
+ if i > 0 and (chosen is None or i < chosen[0] or (i == chosen[0] and len(sym) > len(chosen[1]))):
785
+ chosen = (i, sym, op)
786
+ if chosen is None:
787
+ print_error(
788
+ f"Invalid --filter '{spec}'. Use col=value, col!=value, col~text, or col>/</>=/<= N.",
789
+ "bad_filter",
790
+ )
791
+ raise typer.Exit(1)
792
+ i, sym, op = chosen
793
+ col, val = spec[:i].strip(), spec[i + len(sym):].strip()
794
+ if not col or not val:
795
+ print_error(f"Invalid --filter '{spec}': both a column and a value are required.", "bad_filter")
796
+ raise typer.Exit(1)
797
+ out.append((col, op, val))
798
+ return out
799
+
800
+
801
+ def _cell_matches(cell: object, op: str, val: str) -> bool:
802
+ if op == "contains":
803
+ return cell is not None and val.lower() in str(cell).lower()
804
+ if op in ("eq", "ne"):
805
+ equal = cell is not None and str(cell).strip().lower() == val.strip().lower()
806
+ return equal if op == "eq" else not equal
807
+ try:
808
+ return _NUMERIC_OPS[op](float(cell), float(val)) # gt/lt/gte/lte
809
+ except (TypeError, ValueError):
810
+ return False
811
+
812
+
813
+ def _row_matches(row: object, filters: List[tuple]) -> bool:
814
+ """Client-side predicate (local files). A non-dict row can't match a column
815
+ filter. All filters AND together."""
816
+ if not filters:
817
+ return True
818
+ if not isinstance(row, dict):
819
+ return False
820
+ return all(_cell_matches(row.get(col), op, val) for col, op, val in filters)
821
+
822
+
762
823
  # --------------------------------------------------------------------------
763
824
  # Command
764
825
  # --------------------------------------------------------------------------
@@ -772,6 +833,13 @@ def preview(
772
833
  ] = None,
773
834
  limit: Annotated[int, typer.Option("--limit", "-n", help="Page size when paging a platform dataset")] = 25,
774
835
  offset: Annotated[int, typer.Option("--offset", help="Start at this row index")] = 0,
836
+ filter_: Annotated[
837
+ Optional[List[str]],
838
+ typer.Option(
839
+ "--filter", "-f",
840
+ help="Filter rows: 'col=value', 'col!=value', 'col~text' (contains), or 'col>/</>=/<= N'. Repeatable (AND).",
841
+ ),
842
+ ] = None,
775
843
  title: Annotated[Optional[str], typer.Option("--title", help="Title shown in the viewer header")] = None,
776
844
  hf: Annotated[
777
845
  bool, typer.Option("--hf", help="Treat SOURCE as a HuggingFace repo (org/name); sync it to DataViewer, then view")
@@ -801,13 +869,20 @@ def preview(
801
869
  print_error("The terminal viewer requires 'textual'. Install it: pip install textual", "missing_textual")
802
870
  raise typer.Exit(1)
803
871
 
872
+ filters = _parse_filters(filter_)
804
873
  local_path = Path(source)
805
874
  is_local = (not hf) and local_path.exists() and local_path.is_file()
806
875
 
807
- # Local file → load whole, view immediately.
876
+ # Local file → load whole (filter client-side), view immediately.
808
877
  if is_local:
878
+ rows = _load_local(local_path)
879
+ if filters:
880
+ rows = [r for r in rows if _row_matches(r, filters)]
881
+ if not rows:
882
+ print_error("No rows match the filter(s).", "no_match")
883
+ raise typer.Exit(3)
809
884
  tui_mod.run(
810
- tui_mod.RowSource(initial=_load_local(local_path)),
885
+ tui_mod.RowSource(initial=rows),
811
886
  title or local_path.name,
812
887
  forced_cols=column or None,
813
888
  start_idx=max(0, offset),
@@ -834,11 +909,17 @@ def preview(
834
909
  view_title = title or f"dataset {source}"
835
910
 
836
911
  page_size = limit if limit and limit > 0 else 25
912
+ api_filters = [{"column": col, "operator": op, "value": val} for col, op, val in filters]
837
913
 
838
914
  def _fetch_page(off: int, lim: int) -> List[object]:
839
- data = client.get(
840
- f"/v1/datasets/{q(dataset_id)}/rows", params={"limit": str(lim), "offset": str(offset + off)}
841
- ).json()
915
+ params = {"limit": str(lim), "offset": str(offset + off)}
916
+ if api_filters:
917
+ # Server-side filtering via the same endpoint `eval samples` uses.
918
+ data = client.post(
919
+ f"/v1/datasets/{q(dataset_id)}/rows/filter", json={"filters": api_filters}, params=params
920
+ ).json()
921
+ else:
922
+ data = client.get(f"/v1/datasets/{q(dataset_id)}/rows", params=params).json()
842
923
  return _normalize_loaded(data)
843
924
 
844
925
  # Fetch the first page up front (with feedback) so the viewer opens already
@@ -857,8 +938,13 @@ def preview(
857
938
  if first:
858
939
  break
859
940
  if not first:
860
- msg = f"Dataset returned no rows for split '{split}'." if hf else "Dataset returned no rows."
861
- print_error(msg, "empty_dataset")
941
+ if filters:
942
+ msg, code = "No rows match the filter(s).", "no_match"
943
+ elif hf:
944
+ msg, code = f"Dataset returned no rows for split '{split}'.", "empty_dataset"
945
+ else:
946
+ msg, code = "Dataset returned no rows.", "empty_dataset"
947
+ print_error(msg, code)
862
948
  raise typer.Exit(3)
863
949
  row_source = tui_mod.RowSource(initial=first, fetch_page=_fetch_page, page_size=page_size)
864
950
  tui_mod.run(
@@ -375,22 +375,44 @@ def _field(key: str, value: object, inline_images: bool = False) -> Panel:
375
375
  return Panel(body, title=f"[dim]{escape(str(key))}[/]", title_align="left", border_style="grey30", box=box.MINIMAL)
376
376
 
377
377
 
378
- def render_sample(
378
+ def _field_copy_text(value: object) -> str:
379
+ return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, indent=2, default=str)
380
+
381
+
382
+ def sample_blocks(
379
383
  row: object, forced_cols: Optional[List[str]] = None, width: Optional[int] = None, inline_images: bool = False
380
- ) -> RenderableType:
381
- blocks: List[RenderableType] = []
384
+ ) -> List[tuple]:
385
+ """Return [(renderable, copy_text), ...] one entry per message/field, with
386
+ the raw text to copy. copy_text is None for non-copyable bits (e.g. a column
387
+ label). Single source for both render_sample (Group) and the focusable
388
+ per-block widgets in the viewer."""
389
+ blocks: List[tuple] = []
390
+
391
+ def _conv(raw: list, col: Optional[str]) -> None:
392
+ if col:
393
+ blocks.append((Text(col, style="dim"), None))
394
+ for m in _parse_messages(raw):
395
+ blocks.append((_message(m, width, inline_images), m.get("content") or ""))
396
+
382
397
  if _is_conversation(row):
383
- blocks.extend(_conversation(row, width=width, inline_images=inline_images))
398
+ _conv(row, None)
384
399
  elif isinstance(row, dict):
385
400
  for key, val in row.items():
386
401
  forced = forced_cols is not None and key in forced_cols
387
402
  auto = forced_cols is None and _is_conversation(val)
388
403
  if (forced or auto) and _is_conversation(val):
389
- blocks.extend(_conversation(val, col=key, width=width, inline_images=inline_images))
404
+ _conv(val, key)
390
405
  else:
391
- blocks.append(_field(key, val, inline_images))
406
+ blocks.append((_field(key, val, inline_images), _field_copy_text(val)))
392
407
  else:
393
- blocks.append(_field("value", row, inline_images))
408
+ blocks.append((_field("value", row, inline_images), _field_copy_text(row)))
409
+ return blocks
410
+
411
+
412
+ def render_sample(
413
+ row: object, forced_cols: Optional[List[str]] = None, width: Optional[int] = None, inline_images: bool = False
414
+ ) -> RenderableType:
415
+ blocks = [r for r, _ in sample_blocks(row, forced_cols, width, inline_images)]
394
416
  return Group(*blocks) if blocks else Text("(empty sample)", style="dim")
395
417
 
396
418
 
@@ -533,16 +555,31 @@ def run(
533
555
  except Exception:
534
556
  pass
535
557
 
558
+ class CopyBlock(Static):
559
+ """A focusable block (message/field/sample) that remembers the raw text
560
+ to copy. Tab/Shift+Tab move focus between blocks; `c` copies the focused
561
+ one to the clipboard (via OSC 52, so it works over SSH)."""
562
+
563
+ can_focus = True
564
+
565
+ def __init__(self, renderable, copy_text: str, **kwargs) -> None:
566
+ super().__init__(renderable, **kwargs)
567
+ self.copy_text = copy_text
568
+
536
569
  class Viewer(App):
537
570
  CSS = """
538
571
  Screen { background: $surface; }
539
572
  #body { padding: 1 2; }
540
573
  #body > Static { margin-bottom: 1; }
574
+ CopyBlock { margin-bottom: 1; }
575
+ CopyBlock:focus { background: $boost; }
541
576
  """
542
577
  BINDINGS = [
543
578
  Binding("right,n,l", "next", "Next"),
544
579
  Binding("left,b,h", "prev", "Prev"),
545
- Binding("m,tab", "toggle_mode", "Mode"),
580
+ Binding("m", "toggle_mode", "Mode"),
581
+ Binding("c", "copy", "Copy block"),
582
+ Binding("Y", "copy_sample", "Copy JSON"),
546
583
  Binding("down,j", "scroll_down", "Down", show=False),
547
584
  Binding("up,k", "scroll_up", "Up", show=False),
548
585
  Binding("pagedown,space", "page_down", "Page", show=False),
@@ -591,19 +628,36 @@ def run(
591
628
  def _update_title(self) -> None:
592
629
  self.sub_title = self._counter()
593
630
 
594
- async def _rebuild(self) -> None:
631
+ async def _rebuild(self, preserve_focus: bool = False) -> None:
595
632
  body = self.query_one("#body", VerticalScroll)
633
+ # Remember which copy-block was focused so a width-driven rebuild
634
+ # (resize) doesn't silently change what `c` copies.
635
+ keep_idx = None
636
+ if preserve_focus and self.mode == "pager" and isinstance(self.focused, CopyBlock):
637
+ cbs = list(self.query("#body CopyBlock"))
638
+ if self.focused in cbs:
639
+ keep_idx = cbs.index(self.focused)
596
640
  await body.remove_children()
597
641
  w = self._content_width()
598
642
  self._last_width = w
599
643
  if self.mode == "pager":
600
644
  row = self.source.get(self.idx)
601
645
  # Inline images only in pager mode (one sample) so we never decode/
602
- # fetch images for the whole buffer at once.
603
- content = render_sample(row, forced_cols, width=w, inline_images=True) if row is not None else Text("(no data)")
604
- await body.mount(Static(content))
646
+ # fetch images for the whole buffer at once. Each message/field is a
647
+ # focusable CopyBlock (Tab to move, c to copy).
648
+ widgets = []
649
+ if row is None:
650
+ widgets.append(Static(Text("(no data)")))
651
+ else:
652
+ for rend, copy_text in sample_blocks(row, forced_cols, width=w, inline_images=True):
653
+ widgets.append(Static(rend) if copy_text is None else CopyBlock(rend, copy_text))
654
+ await body.mount(*widgets)
605
655
  body.scroll_home(animate=False)
606
656
  self._mounted = 1
657
+ cbs = [wgt for wgt in widgets if isinstance(wgt, CopyBlock)]
658
+ target = cbs[keep_idx] if (keep_idx is not None and keep_idx < len(cbs)) else (cbs[0] if cbs else None)
659
+ if target is not None:
660
+ target.focus()
607
661
  else:
608
662
  n = self.source.count()
609
663
  widgets = []
@@ -611,7 +665,9 @@ def run(
611
665
  row = self.source.get(i)
612
666
  label = f"sample {i + 1 + self.index_base}/{n + self.index_base}{'+' if self.source.has_more else ''}"
613
667
  header = Rule(label, style="grey42")
614
- widgets.append(Static(Group(header, render_sample(row, forced_cols, width=w)), id=f"s{i}"))
668
+ # In scroll mode each whole sample is one focusable block (c copies the row JSON).
669
+ body_r = Group(header, render_sample(row, forced_cols, width=w))
670
+ widgets.append(CopyBlock(body_r, _field_copy_text(row), id=f"s{i}"))
615
671
  if widgets:
616
672
  await body.mount(*widgets)
617
673
  self._mounted = n
@@ -619,16 +675,18 @@ def run(
619
675
  self._update_title()
620
676
 
621
677
  async def on_resize(self, event) -> None:
622
- # Re-flow bubble widths when the terminal size changes.
678
+ # Re-flow bubble widths when the terminal size changes; keep the
679
+ # focused block so `c` still copies what the user selected.
623
680
  if self._loading:
624
681
  return
625
682
  if self._content_width() != self._last_width:
626
- await self._rebuild()
683
+ await self._rebuild(preserve_focus=True)
627
684
 
628
685
  def _scroll_to_current(self) -> None:
629
686
  try:
630
687
  target = self.query_one(f"#s{self.idx}")
631
688
  self.query_one("#body", VerticalScroll).scroll_to_widget(target, animate=False, top=True)
689
+ target.focus() # so `c` copies the current sample in scroll mode
632
690
  except Exception:
633
691
  pass
634
692
 
@@ -705,4 +763,18 @@ def run(
705
763
  err = _play_audio(clips[0])
706
764
  self._set_status(f"⚠ {err}" if err else "▶ playing audio…")
707
765
 
766
+ def action_copy(self) -> None:
767
+ focused = self.focused
768
+ if isinstance(focused, CopyBlock):
769
+ self.copy_to_clipboard(focused.copy_text)
770
+ n = len(focused.copy_text)
771
+ self._set_status(f"copied {n} char{'s' if n != 1 else ''} (OSC 52)")
772
+ else:
773
+ self._set_status("Tab to focus a block, then c to copy")
774
+
775
+ def action_copy_sample(self) -> None:
776
+ row = self.source.get(self.idx)
777
+ self.copy_to_clipboard(json.dumps(row, ensure_ascii=False, indent=2, default=str))
778
+ self._set_status("copied sample JSON ✓")
779
+
708
780
  Viewer().run()
@@ -185,7 +185,7 @@ wheels = [
185
185
 
186
186
  [[package]]
187
187
  name = "lql-cli"
188
- version = "0.5.0"
188
+ version = "0.7.0"
189
189
  source = { editable = "." }
190
190
  dependencies = [
191
191
  { name = "httpx" },
@@ -1 +0,0 @@
1
- __version__ = "0.5.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes