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.
- {lql_cli-0.5.0 → lql_cli-0.7.0}/PKG-INFO +15 -2
- {lql_cli-0.5.0 → lql_cli-0.7.0}/README.md +14 -1
- {lql_cli-0.5.0 → lql_cli-0.7.0}/pyproject.toml +1 -1
- lql_cli-0.7.0/src/lql/__init__.py +1 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/instructions.py +17 -3
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/preview.py +93 -7
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/tui.py +87 -15
- {lql_cli-0.5.0 → lql_cli-0.7.0}/uv.lock +1 -1
- lql_cli-0.5.0/src/lql/__init__.py +0 -1
- {lql_cli-0.5.0 → lql_cli-0.7.0}/.claude/settings.local.json +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/.gitignore +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/examples/agent-traces.jsonl +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/package-lock.json +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/_group.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/_opts.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/api.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/cli.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/__init__.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/annotations.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/auth.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/buckets.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/datasets.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/edits.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/evals.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/highlights.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/issues.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/reports.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/skills.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/spec.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/update.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/commands/workspaces.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/config.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/output.py +0 -0
- {lql_cli-0.5.0 → lql_cli-0.7.0}/src/lql/sessions.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
```
|
|
@@ -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
|
|
104
|
-
(start row index), --title, --hf, --split,
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
861
|
-
|
|
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
|
|
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
|
-
) ->
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
604
|
-
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|