nbsee 0.1.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.
nbsee-0.1.0/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 nbsee contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,7 @@
1
+ include README.md
2
+ include LICENSE
3
+ recursive-include src *.py
4
+ recursive-include docs *.md
5
+ recursive-include tests *.py
6
+ global-exclude __pycache__ *.py[cod]
7
+ exclude *.ipynb
nbsee-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: nbsee
3
+ Version: 0.1.0
4
+ Summary: Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs
5
+ Author: Yoga Yudistira
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AnakTeka/nbsee
8
+ Project-URL: Repository, https://github.com/AnakTeka/nbsee
9
+ Project-URL: Issues, https://github.com/AnakTeka/nbsee/issues
10
+ Keywords: jupyter,notebook,cli,tui,rich,textual
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Environment :: Console
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: rich>=13.7
22
+ Requires-Dist: textual>=0.58
23
+ Dynamic: license-file
24
+
25
+ nbsee
26
+ =====
27
+
28
+ Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
29
+
30
+ Status: planning/design. See docs/TODO.md for the working task list.
31
+
32
+ Goals (MVP)
33
+ - Interactive terminal viewer for `.ipynb`.
34
+ - Navigate cells with keys; focus input vs output.
35
+ - Code cells: show source and text/plain outputs only.
36
+ - Easy copying from the viewer using the keyboard (xclip).
37
+ - Ignore images/HTML-rich outputs for now.
38
+
39
+ Quick Concept (not implemented yet)
40
+ - `nbsee notebook.ipynb` — open an interactive viewer.
41
+
42
+ Key bindings (proposed)
43
+ - `j`/`k`: move to next/previous cell
44
+ - Arrow Up/Down: scroll the view line-by-line
45
+ - PageUp/PageDown: page scroll
46
+ - Ctrl+u / Ctrl+d: half-page scroll up/down
47
+ - `Tab`: toggle focus between input (code/markdown) and output (text/plain)
48
+ - If a cell has no text output, `Tab` does nothing and shows a brief message.
49
+ - `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
50
+ - `z`: hide/show all outputs (fold outputs)
51
+ - `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
52
+ - `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
53
+ - `n` / `N`: next/prev match
54
+ - `gg` / `G`: jump to first / last cell
55
+ - `G`/`gg`: jump to last/first cell (optional)
56
+ - `q`: quit
57
+
58
+ Notes
59
+ - Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
60
+ - When an output is not available (e.g., no text/plain), copy will show an inline message.
61
+ - Python syntax highlighting is built-in via Rich.
62
+
63
+ Install (PyPI)
64
+ - `python -m pip install nbsee`
65
+ - Run: `nbsee path/to/notebook.ipynb`
66
+
67
+ Development (uv)
68
+ - Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
69
+ - Run tests: `uv run pytest`
70
+ - Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
71
+ - Build distributions: `uv build`
72
+ - Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
73
+ - Install editable and use `nbsee` command:
74
+ - Create venv: `uv venv`
75
+ - Activate: `source .venv/bin/activate`
76
+ - Install: `uv pip install -e .`
77
+ - Run: `nbsee path/to/notebook.ipynb`
78
+ - Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
79
+
80
+
81
+ Docs
82
+ - Design: `docs/DESIGN.md`
83
+ - TODOs & roadmap: `docs/TODO.md`
nbsee-0.1.0/README.md ADDED
@@ -0,0 +1,59 @@
1
+ nbsee
2
+ =====
3
+
4
+ Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
5
+
6
+ Status: planning/design. See docs/TODO.md for the working task list.
7
+
8
+ Goals (MVP)
9
+ - Interactive terminal viewer for `.ipynb`.
10
+ - Navigate cells with keys; focus input vs output.
11
+ - Code cells: show source and text/plain outputs only.
12
+ - Easy copying from the viewer using the keyboard (xclip).
13
+ - Ignore images/HTML-rich outputs for now.
14
+
15
+ Quick Concept (not implemented yet)
16
+ - `nbsee notebook.ipynb` — open an interactive viewer.
17
+
18
+ Key bindings (proposed)
19
+ - `j`/`k`: move to next/previous cell
20
+ - Arrow Up/Down: scroll the view line-by-line
21
+ - PageUp/PageDown: page scroll
22
+ - Ctrl+u / Ctrl+d: half-page scroll up/down
23
+ - `Tab`: toggle focus between input (code/markdown) and output (text/plain)
24
+ - If a cell has no text output, `Tab` does nothing and shows a brief message.
25
+ - `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
26
+ - `z`: hide/show all outputs (fold outputs)
27
+ - `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
28
+ - `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
29
+ - `n` / `N`: next/prev match
30
+ - `gg` / `G`: jump to first / last cell
31
+ - `G`/`gg`: jump to last/first cell (optional)
32
+ - `q`: quit
33
+
34
+ Notes
35
+ - Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
36
+ - When an output is not available (e.g., no text/plain), copy will show an inline message.
37
+ - Python syntax highlighting is built-in via Rich.
38
+
39
+ Install (PyPI)
40
+ - `python -m pip install nbsee`
41
+ - Run: `nbsee path/to/notebook.ipynb`
42
+
43
+ Development (uv)
44
+ - Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
45
+ - Run tests: `uv run pytest`
46
+ - Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
47
+ - Build distributions: `uv build`
48
+ - Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
49
+ - Install editable and use `nbsee` command:
50
+ - Create venv: `uv venv`
51
+ - Activate: `source .venv/bin/activate`
52
+ - Install: `uv pip install -e .`
53
+ - Run: `nbsee path/to/notebook.ipynb`
54
+ - Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
55
+
56
+
57
+ Docs
58
+ - Design: `docs/DESIGN.md`
59
+ - TODOs & roadmap: `docs/TODO.md`
@@ -0,0 +1,73 @@
1
+ Design Notes
2
+ ============
3
+
4
+ Problem
5
+ - Quickly view Jupyter notebook cells in a terminal and easily copy either the code or the text/plain output.
6
+ - Avoid heavy rendering; keep it fast and focused on text.
7
+
8
+ Constraints / Non-goals (MVP)
9
+ - Do not render images, rich HTML, plots, or dataframes (treat them as placeholders or omit).
10
+ - Do not execute notebooks; viewing only.
11
+ - No in-place editing of notebooks.
12
+
13
+ CLI UX (proposed)
14
+ - `nbsee <notebook.ipynb>`: open an interactive viewer.
15
+ - Multi-cell list layout with vertical scrolling provides context similar to a notebook editor.
16
+ - Each cell renders: header `[idx/type]`, Input, separator, Output, and spacing.
17
+ - Selection (J/K) highlights a cell header; focus marker (Tab) toggles Input/Output of the selected cell for copying.
18
+ - Exit codes: 0 on success; non-zero on IO/parse errors.
19
+
20
+ Keymap (MVP)
21
+ - `J` / `K`: next / previous cell (change selection)
22
+ - Arrow Up/Down: scroll the view line-by-line
23
+ - PageUp/PageDown: page scroll
24
+ - `Tab`: toggle focus between Input and Output for selected cell (no-op if no text output)
25
+ - `Ctrl+u` / `Ctrl+d`: scroll up/down by half a page
26
+ - `z`: toggle hide/show of all outputs (global fold)
27
+ - `Shift+V`: enter/exit visual selection for inputs; `j`/`k` adjust the range; `y` yanks (copies) all selected code cells to clipboard
28
+ - `/`: open a search prompt (inputs + outputs, case-insensitive)
29
+ - `n` / `N`: jump to next/previous match, wrapping around
30
+ - `gg` / `G`: jump to first/last cell
31
+ - `y`: copy focused region to clipboard using `xclip -selection clipboard`
32
+ - `G` / `gg`: jump to last / first cell (optional)
33
+ - `q`: quit
34
+
35
+ Copying Workflow
36
+ - Interactive: pressing `C` copies the currently focused region (Input or Output) to the clipboard via `xclip -selection clipboard` on Linux/X11.
37
+ - If Output is focused but the cell has no text/plain output, show a temporary inline message and do nothing.
38
+ - Piping-based copying is not required in MVP; we optimize for the interactive flow.
39
+
40
+ Rendering
41
+ - Multi-cell stacked view; soft-wrap long lines to terminal width.
42
+ - Rich/Textual UI only: panels with clear separation and high-quality `Syntax` highlighting.
43
+ - No horizontal scrolling in MVP; lines are wrapped.
44
+
45
+ Parsing
46
+ - Use Python stdlib `json` for MVP (avoid deps); consider `nbformat` later for robustness.
47
+ - Fields: `cells[]`, `cell_type`, `source`, `outputs[]`, `output_type`, `text`, `data["text/plain"]`.
48
+ - Be robust to missing fields.
49
+
50
+ Performance
51
+ - Stream parsing is not necessary for typical sizes.
52
+ - Avoid expensive re-renders; redraw only on navigation or copy feedback.
53
+
54
+ Structure (proposed)
55
+ - `nbsee/` package with `__main__.py` entry point.
56
+ - Single UI: Rich/Textual-based TUI with `textual_app.py`.
57
+ - `pyproject.toml` exposes console script `nbsee` and depends on `rich` and `textual`.
58
+
59
+ Error Handling
60
+ - Clear messages for: file not found, invalid JSON, not a notebook, cell out of range, missing output.
61
+ - In-view ephemeral notices for copy failures (e.g., xclip missing or no output to copy).
62
+
63
+ Future Ideas (post-MVP)
64
+ - Multi-cell list pane with scrolling and preview.
65
+ - Rich/HTML output fallback with `rich`/`textual`.
66
+ - Image placeholders with metadata (e.g., [image output: 800x600]).
67
+ - Search/filter cells by content or tags.
68
+ - Copy multiple cells or ranges.
69
+
70
+ Developer Workflow (uv)
71
+ - Quick run without install: `uv run -m nbsee path/to/notebook.ipynb`
72
+ - Create venv: `uv venv && source .venv/bin/activate`
73
+ - Install editable: `uv pip install -e .`
@@ -0,0 +1,47 @@
1
+ nbsee TODOs
2
+ ============
3
+
4
+ MVP Scope (Interactive viewer)
5
+ - [x] CLI skeleton: `nbsee <path.ipynb>` opens TUI
6
+ - [x] Validate path and read file
7
+ - [x] Parse notebook JSON (cells, types, sources)
8
+ - [x] Multi-cell stacked layout with scrolling
9
+ - [x] Render Input region (code/markdown/raw)
10
+ - [x] Render Output region for code cells (text/plain only)
11
+ - [x] Focus toggling: `Tab` cycles Input/Output
12
+ - [x] Navigation: `J`/`K` select cells; `↑/↓` scroll; `PgUp/PgDn` page
13
+ - [x] Copy: `y` copies focused region via `xclip -selection clipboard`
14
+ - [x] Feedback: status line for copy success/failure
15
+ - [ ] Handle errors (file not found, bad JSON, not a notebook, bad cell index) — basic done, refine messages
16
+ - [ ] Tests for parsing and selection logic
17
+ - [ ] Basic docs and examples
18
+
19
+ Rich/Textual UI
20
+ - [x] Use Rich/Textual as the default (and only) UI
21
+ - [ ] Style tuning for panels, themes, and readability
22
+ - [ ] Add cell collapse/expand interactions
23
+ - [ ] Add search ("/") and go-to (":N")
24
+ - [x] Global fold toggle for outputs (`z`)
25
+ - [x] Visual selection of inputs with `Shift+V`, adjust with `j/k`, yank with `y`
26
+
27
+ Copying Experience
28
+ - [ ] Linux/X11 clipboard via `xclip -selection clipboard`
29
+ - [ ] Detect missing `xclip` and show helpful message
30
+ - [ ] Optional later: Wayland (`wl-copy`) and macOS (`pbcopy`) support
31
+
32
+ Rendering
33
+ - [x] Simple, readable headers and focus markers (Rich panels)
34
+ - [x] Python syntax highlighting via Rich `Syntax`
35
+
36
+ Packaging
37
+ - [ ] `pyproject.toml` with console script entry `nbsee`
38
+ - [x] Depend on `rich` and `textual` directly (no extras)
39
+ - [ ] Document `uv` workflow (run, venv, install editable)
40
+
41
+ Post-MVP / Later
42
+ - [ ] Cell range display `--range A:B`
43
+ - [ ] Colorized code (Pygments)
44
+ - [ ] Rich outputs (HTML/images) placeholders
45
+ - [ ] Interactive TUI mode (Textual)
46
+ - [ ] Search/filter cells
47
+ - [ ] Copy multiple cells/ranges
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nbsee"
7
+ version = "0.1.0"
8
+ description = "Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Yoga Yudistira" }]
14
+ keywords = ["jupyter", "notebook", "cli", "tui", "rich", "textual"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Environment :: Console",
22
+ "Topic :: Utilities",
23
+ ]
24
+ dependencies = ["rich>=13.7", "textual>=0.58"]
25
+
26
+ [project.scripts]
27
+ nbsee = "nbsee.__main__:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/AnakTeka/nbsee"
31
+ Repository = "https://github.com/AnakTeka/nbsee"
32
+ Issues = "https://github.com/AnakTeka/nbsee/issues"
33
+
34
+
35
+ [dependency-groups]
36
+ test = ["pytest>=9.0.0"]
37
+ lint = ["ruff>=0.8.0"]
38
+ dev = [{ include-group = "test" }, { include-group = "lint" }]
39
+
40
+ [tool.setuptools]
41
+ package-dir = { "" = "src" }
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.ruff]
50
+ target-version = "py310"
51
+ line-length = 88
52
+ exclude = ["*.ipynb"]
53
+
54
+ [tool.ruff.lint]
55
+ select = ["E", "F", "I", "UP", "B"]
nbsee-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __all__ = []
@@ -0,0 +1,116 @@
1
+ import argparse
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from typing import Any
8
+
9
+
10
+ def _read_file(path: str) -> dict[str, Any]:
11
+ with open(path, encoding="utf-8") as f:
12
+ return json.load(f)
13
+
14
+
15
+ def _as_text(value: Any) -> str:
16
+ if value is None:
17
+ return ""
18
+ if isinstance(value, list):
19
+ return "".join(str(x) for x in value)
20
+ return str(value)
21
+
22
+
23
+ def _cell_input_text(cell: dict[str, Any]) -> str:
24
+ return _as_text(cell.get("source", ""))
25
+
26
+
27
+ def _cell_output_text(cell: dict[str, Any]) -> str:
28
+ if cell.get("cell_type") != "code":
29
+ return ""
30
+ outs: list[str] = []
31
+ for out in cell.get("outputs", []) or []:
32
+ data = out.get("data") if isinstance(out, dict) else None
33
+ if isinstance(data, dict) and "text/plain" in data:
34
+ outs.append(_as_text(data.get("text/plain")))
35
+ continue
36
+ if out.get("output_type") == "stream" and "text" in out:
37
+ outs.append(_as_text(out.get("text")))
38
+ continue
39
+ if out.get("output_type") == "error" and "traceback" in out:
40
+ tb = out.get("traceback")
41
+ if isinstance(tb, list):
42
+ parts = [str(x) for x in tb]
43
+ # Jupyter tracebacks are often lists of lines. Sometimes newlines are
44
+ # embedded, sometimes they're separate elements; preserve embedded ones.
45
+ if any("\n" in p for p in parts):
46
+ outs.append("".join(parts))
47
+ else:
48
+ outs.append("\n".join(parts))
49
+ else:
50
+ outs.append(_as_text(tb))
51
+ continue
52
+ return "\n".join(x for x in outs if x)
53
+
54
+
55
+ def _load_notebook(path: str) -> list[dict[str, Any]]:
56
+ nb = _read_file(path)
57
+ if (
58
+ not isinstance(nb, dict)
59
+ or "cells" not in nb
60
+ or not isinstance(nb["cells"], list)
61
+ ):
62
+ raise ValueError("Not a valid .ipynb: missing cells[]")
63
+ return nb["cells"]
64
+
65
+
66
+ def _copy_to_clipboard(text: str) -> tuple[bool, str]:
67
+ if not text:
68
+ return False, "Nothing to copy"
69
+ if shutil.which("xclip") is None:
70
+ return False, "xclip not found"
71
+ try:
72
+ p = subprocess.Popen(
73
+ ["xclip", "-selection", "clipboard"], stdin=subprocess.PIPE
74
+ )
75
+ assert p.stdin is not None
76
+ p.stdin.write(text.encode("utf-8"))
77
+ p.stdin.close()
78
+ rc = p.wait(timeout=5)
79
+ if rc != 0:
80
+ return False, f"xclip failed (code {rc})"
81
+ return True, f"Copied {len(text)} chars to clipboard"
82
+ except FileNotFoundError:
83
+ return False, "xclip not found"
84
+ except Exception as e:
85
+ return False, f"Copy failed: {e}"
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ parser = argparse.ArgumentParser(
90
+ description="Rich/Textual viewer for Jupyter notebooks"
91
+ )
92
+ parser.add_argument("path", help="Path to .ipynb file")
93
+ args = parser.parse_args(argv)
94
+
95
+ if not os.path.exists(args.path):
96
+ sys.stderr.write(f"File not found: {args.path}\n")
97
+ return 2
98
+ try:
99
+ cells = _load_notebook(args.path)
100
+ except Exception as e:
101
+ sys.stderr.write(f"Failed to read notebook: {e}\n")
102
+ return 2
103
+ if not cells:
104
+ sys.stderr.write("Notebook has no cells\n")
105
+ return 1
106
+
107
+ try:
108
+ from .textual_app import run_textual_app
109
+ except Exception as e: # pragma: no cover
110
+ sys.stderr.write(f"Rich/Textual UI not available: {e}\n")
111
+ return 2
112
+ return run_textual_app(cells)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ raise SystemExit(main())
@@ -0,0 +1,610 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ try:
7
+ from rich import box as rich_box
8
+ from rich.panel import Panel
9
+ from rich.syntax import Syntax
10
+ from rich.text import Text
11
+ from textual.app import App, ComposeResult
12
+ from textual.binding import Binding
13
+ from textual.containers import VerticalScroll
14
+ from textual.reactive import reactive
15
+ from textual.widgets import Footer, Input, Static
16
+
17
+ _HAS_TEXTUAL = True
18
+ except Exception as e: # pragma: no cover
19
+ _HAS_TEXTUAL = False
20
+ _IMPORT_ERROR = e
21
+
22
+ from .__main__ import _cell_input_text, _cell_output_text, _copy_to_clipboard
23
+
24
+ _ANSI_CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
25
+ _ANSI_OSC_RE = re.compile(r"\x1b\].*?(?:\x07|\x1b\\)")
26
+ _ANSI_SGR_RE = re.compile(r"\x1b\[([0-9;]*)m")
27
+ _CONTROL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
28
+
29
+
30
+ def _strip_sgr_background(text: str) -> str:
31
+ def repl(match: re.Match[str]) -> str:
32
+ raw = match.group(1)
33
+ if raw == "":
34
+ return match.group(0)
35
+
36
+ try:
37
+ params = [int(p) for p in raw.split(";") if p != ""]
38
+ except ValueError:
39
+ return match.group(0)
40
+
41
+ keep: list[int] = []
42
+ i = 0
43
+ while i < len(params):
44
+ p = params[i]
45
+ # Drop background colors:
46
+ # - 40-47, 100-107, 49
47
+ # - 48;5;n and 48;2;r;g;b
48
+ if p == 48:
49
+ if i + 1 < len(params) and params[i + 1] == 5:
50
+ i += 3
51
+ continue
52
+ if i + 1 < len(params) and params[i + 1] == 2:
53
+ i += 5
54
+ continue
55
+ i += 1
56
+ continue
57
+ if p == 49 or 40 <= p <= 47 or 100 <= p <= 107:
58
+ i += 1
59
+ continue
60
+ keep.append(p)
61
+ i += 1
62
+
63
+ if not keep:
64
+ return ""
65
+ return f"\x1b[{';'.join(str(p) for p in keep)}m"
66
+
67
+ return _ANSI_SGR_RE.sub(repl, text)
68
+
69
+
70
+ def _sanitize_ansi_text(text: str) -> str:
71
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
72
+ text = _ANSI_OSC_RE.sub("", text)
73
+
74
+ def csi_repl(match: re.Match[str]) -> str:
75
+ seq = match.group(0)
76
+ return seq if seq.endswith("m") else ""
77
+
78
+ text = _ANSI_CSI_RE.sub(csi_repl, text)
79
+ text = re.sub(r"\x1b(?!\[[0-9;]*m)", "", text)
80
+ text = _CONTROL_RE.sub("", text)
81
+ # IPython tracebacks sometimes render "Traceback ...Cell ..." without a newline.
82
+ text = re.sub(r"(Traceback \(most recent call last\))\s*Cell", r"\1\nCell", text)
83
+ return text
84
+
85
+
86
+ def ensure_textual_available():
87
+ if not _HAS_TEXTUAL:
88
+ raise RuntimeError(
89
+ f"Rich/Textual UI requested but not available: {_IMPORT_ERROR}"
90
+ )
91
+
92
+
93
+ if _HAS_TEXTUAL:
94
+
95
+ class CellView(Static):
96
+ selected: bool = reactive(False)
97
+ focus_region: str = reactive("input") # input|output
98
+ outputs_hidden: bool = reactive(False)
99
+ in_visual: bool = reactive(False) # in multi-cell visual selection range
100
+
101
+ def __init__(self, index: int, total: int, cell: dict[str, Any]):
102
+ super().__init__(id=f"cell-{index}")
103
+ self.index = index
104
+ self.total = total
105
+ self.cell = cell
106
+
107
+ def _render_input(self) -> Syntax | Text:
108
+ ctype = self.cell.get("cell_type")
109
+ source = _cell_input_text(self.cell)
110
+ if ctype == "code":
111
+ # Use Rich Syntax highlighter for Python
112
+ return Syntax(source or "", "python", theme="monokai", word_wrap=True)
113
+ # Markdown/raw rendered as plain text for now
114
+ return Text(source or "", style="default")
115
+
116
+ def _render_output(self) -> Text:
117
+ txt = _cell_output_text(self.cell)
118
+ if not (txt or "").strip():
119
+ t = Text("[no text output]", style="grey50 italic")
120
+ t.no_wrap = False
121
+ t.overflow = "fold"
122
+ return t
123
+
124
+ txt = txt or ""
125
+ if self._has_error_output():
126
+ cleaned = _strip_sgr_background(_sanitize_ansi_text(txt))
127
+ t = Text.from_ansi(f"{cleaned}\x1b[0m")
128
+ elif "\x1b" in txt:
129
+ cleaned = _sanitize_ansi_text(txt)
130
+ t = Text.from_ansi(f"{cleaned}\x1b[0m")
131
+ else:
132
+ t = Text(_sanitize_ansi_text(txt), style="grey70")
133
+ t.no_wrap = False
134
+ t.overflow = "fold"
135
+ return t
136
+
137
+ def _has_error_output(self) -> bool:
138
+ try:
139
+ for out in self.cell.get("outputs", []) or []:
140
+ if out.get("output_type") == "error":
141
+ return True
142
+ except Exception:
143
+ pass
144
+ txt = _cell_output_text(self.cell) or ""
145
+ return "Traceback (most recent call last)" in txt
146
+
147
+ def render(self):
148
+ # Header: color indicates selection
149
+ # Header styling
150
+ if self.selected:
151
+ header_style = "magenta bold"
152
+ prefix = "▶ "
153
+ elif self.in_visual:
154
+ header_style = "yellow"
155
+ prefix = "◆ "
156
+ else:
157
+ header_style = "grey50"
158
+ prefix = " "
159
+ cell_type = self.cell.get("cell_type", "?")
160
+ header_text = f"[ {self.index + 1}/{self.total} | {cell_type} ]"
161
+ header = Text(prefix + header_text, style=header_style)
162
+
163
+ # Input and Output as rounded panels with clear distinction
164
+ input_border = (
165
+ "cyan" if (self.selected and self.focus_region == "input") else "grey50"
166
+ )
167
+ if self.selected and self.focus_region == "output":
168
+ output_border = "cyan"
169
+ elif self._has_error_output():
170
+ output_border = "red"
171
+ else:
172
+ output_border = "grey37"
173
+
174
+ input_panel = Panel(
175
+ self._render_input(),
176
+ box=rich_box.ROUNDED,
177
+ border_style=input_border,
178
+ padding=(0, 1),
179
+ )
180
+
181
+ parts = [header, input_panel]
182
+ out_text = _cell_output_text(self.cell)
183
+ if out_text.strip():
184
+ if self.outputs_hidden:
185
+ out_render = Text("[output hidden]", style="grey37 italic")
186
+ else:
187
+ out_render = self._render_output()
188
+ title = "Error" if self._has_error_output() else "Output"
189
+ output_panel = Panel(
190
+ out_render,
191
+ title=title,
192
+ box=rich_box.ROUNDED,
193
+ border_style=output_border,
194
+ padding=(0, 1),
195
+ )
196
+ parts.append(output_panel)
197
+
198
+ from rich.console import Group
199
+
200
+ return Group(*parts)
201
+
202
+ class NotebookApp(App):
203
+ CSS = """
204
+ Screen { layers: base; }
205
+ VerticalScroll { padding: 0 1; }
206
+ #searchbar { dock: top; padding: 0 1; }
207
+ #footer { dock: bottom; }
208
+ """
209
+
210
+ BINDINGS = [
211
+ Binding("q", "quit", "Quit"),
212
+ Binding("j", "next_cell", "Next cell"),
213
+ Binding("k", "prev_cell", "Prev cell"),
214
+ # Override Textual's default focus-next so Tab toggles input/output
215
+ Binding("tab", "toggle_focus", "Toggle focus", priority=True),
216
+ Binding(
217
+ "shift+tab",
218
+ "toggle_focus",
219
+ "Toggle focus",
220
+ priority=True,
221
+ show=False,
222
+ ),
223
+ Binding("y", "copy", "Copy"),
224
+ Binding("ctrl+d", "half_down", "Half down"),
225
+ Binding("ctrl+u", "half_up", "Half up"),
226
+ Binding("z", "toggle_outputs", "Hide/show outputs"),
227
+ Binding("shift+v", "toggle_visual", "Visual (inputs)"),
228
+ Binding("/", "search_prompt", "Search"),
229
+ Binding("escape", "cancel_search", "Cancel search", show=False),
230
+ Binding("n", "search_next", "Next match"),
231
+ Binding("shift+n", "search_prev", "Prev match", show=False),
232
+ Binding("G", "go_last", "Last cell"),
233
+ Binding("g", "go_prefix", "g (prefix)", show=False),
234
+ ]
235
+
236
+ def __init__(self, cells: list[dict[str, Any]]):
237
+ super().__init__()
238
+ self.cells = cells
239
+ self.selected = 0
240
+ self._cell_views: list[CellView] = []
241
+ self.hide_outputs = False
242
+ self.visual_mode = False
243
+ self.visual_anchor = 0
244
+ self.search_query: str = ""
245
+ # For handling "gg" sequence
246
+ self._g_pending = False
247
+ self._g_timer = None
248
+
249
+ def compose(self) -> ComposeResult:
250
+ total = len(self.cells)
251
+ views: list[CellView] = []
252
+ for i, cell in enumerate(self.cells):
253
+ cv = CellView(i, total, cell)
254
+ views.append(cv)
255
+ self._cell_views = views
256
+ yield VerticalScroll(*views, id="cells")
257
+ yield Footer(id="footer")
258
+
259
+ def on_mount(self) -> None:
260
+ self._update_selection(0)
261
+
262
+ def action_next_cell(self) -> None:
263
+ if self.selected < len(self._cell_views) - 1:
264
+ self._update_selection(self.selected + 1)
265
+ if self.visual_mode:
266
+ self._update_visual_flags()
267
+
268
+ def action_prev_cell(self) -> None:
269
+ if self.selected > 0:
270
+ self._update_selection(self.selected - 1)
271
+ if self.visual_mode:
272
+ self._update_visual_flags()
273
+
274
+ def _scroll(self, delta: int) -> int:
275
+ try:
276
+ sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
277
+ except Exception:
278
+ return 0
279
+ # Determine current y offset and target
280
+ current_y = 0
281
+ try:
282
+ off = getattr(sc, "scroll_offset", None)
283
+ if off is not None and hasattr(off, "y"):
284
+ current_y = int(off.y)
285
+ else:
286
+ current_y = int(getattr(sc, "scroll_y", 0))
287
+ except Exception:
288
+ pass
289
+ target = max(0, current_y + int(delta))
290
+ # Perform scroll using best available API
291
+ try:
292
+ sc.scroll_to(y=target, animate=False)
293
+ return target
294
+ except TypeError:
295
+ try:
296
+ sc.scroll_to(0, target, animate=False)
297
+ return target
298
+ except Exception:
299
+ pass
300
+ except Exception:
301
+ pass
302
+ # Try duration=0 as a fallback to disable animation
303
+ try:
304
+ sc.scroll_to(y=target, duration=0)
305
+ return target
306
+ except Exception:
307
+ pass
308
+ try:
309
+ sc.scroll_y = target # type: ignore[attr-defined]
310
+ except Exception:
311
+ pass
312
+ return target
313
+
314
+ def _visible_cells(self) -> list[int]:
315
+ # Return indices of cells currently intersecting the viewport
316
+ try:
317
+ sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
318
+ except Exception:
319
+ return []
320
+ try:
321
+ sy = int(getattr(sc.scroll_offset, "y", getattr(sc, "scroll_y", 0)))
322
+ except Exception:
323
+ sy = int(getattr(sc, "scroll_y", 0))
324
+ try:
325
+ vh = (
326
+ int(getattr(sc.size, "height", 0))
327
+ or int(getattr(self.size, "height", 0))
328
+ or 20
329
+ )
330
+ except Exception:
331
+ vh = 20
332
+ vis: list[int] = []
333
+ for i, cv in enumerate(self._cell_views):
334
+ try:
335
+ y = int(getattr(cv.region, "y", 0))
336
+ h = max(1, int(getattr(cv.region, "height", 1)))
337
+ except Exception:
338
+ continue
339
+ if (y < sy + vh) and (y + h > sy):
340
+ vis.append(i)
341
+ return vis
342
+
343
+ def action_half_down(self) -> None:
344
+ try:
345
+ sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
346
+ height = (
347
+ int(getattr(sc.size, "height", 0))
348
+ or int(getattr(self.size, "height", 0))
349
+ or 20
350
+ )
351
+ except Exception:
352
+ height = 20
353
+ delta = max(5, height // 2)
354
+ if self.visual_mode:
355
+ self._set_visual(False)
356
+ self._scroll(delta)
357
+ vis = self._visible_cells()
358
+ step = max(1, len(vis) // 2 or 1)
359
+ new_idx = min(len(self._cell_views) - 1, self.selected + step)
360
+ self._update_selection(new_idx, ensure_visible=False)
361
+
362
+ def action_half_up(self) -> None:
363
+ try:
364
+ sc: VerticalScroll = self.query_one("#cells", VerticalScroll)
365
+ height = (
366
+ int(getattr(sc.size, "height", 0))
367
+ or int(getattr(self.size, "height", 0))
368
+ or 20
369
+ )
370
+ except Exception:
371
+ height = 20
372
+ delta = -max(5, height // 2)
373
+ if self.visual_mode:
374
+ self._set_visual(False)
375
+ self._scroll(delta)
376
+ vis = self._visible_cells()
377
+ step = max(1, len(vis) // 2 or 1)
378
+ new_idx = max(0, self.selected - step)
379
+ self._update_selection(new_idx, ensure_visible=False)
380
+
381
+ # --- Search ---
382
+ def _cell_text(self, idx: int) -> str:
383
+ cell = self.cells[idx]
384
+ return f"{_cell_input_text(cell)}\n{_cell_output_text(cell)}"
385
+
386
+ def _search_from(self, start: int, step: int) -> int:
387
+ if not self.search_query:
388
+ return -1
389
+ q = self.search_query.lower()
390
+ n = len(self.cells)
391
+ i = start
392
+ for _ in range(n):
393
+ text = self._cell_text(i).lower()
394
+ if q in text:
395
+ return i
396
+ i = (i + step) % n
397
+ return -1
398
+
399
+ def action_search_prompt(self) -> None:
400
+ # Open a simple input bar at the top
401
+ if self.query("#searchbar"):
402
+ sb = self.query_one("#searchbar", Input)
403
+ self.call_after_refresh(sb.focus)
404
+ return
405
+ sb = Input(placeholder="/ search", id="searchbar")
406
+ sb.value = self.search_query
407
+ self.mount(sb)
408
+ self.call_after_refresh(sb.focus)
409
+
410
+ def action_cancel_search(self) -> None:
411
+ if self.query("#searchbar"):
412
+ try:
413
+ self.query_one("#searchbar", Input).remove()
414
+ except Exception:
415
+ pass
416
+
417
+ def on_input_submitted(self, event: Input.Submitted) -> None:
418
+ if event.input.id == "searchbar":
419
+ self.search_query = event.value or ""
420
+ event.input.remove()
421
+ if self.search_query:
422
+ self.action_search_next()
423
+ else:
424
+ self.notify("Search cleared")
425
+
426
+ def on_input_blurred(self, event: Input.Blurred) -> None:
427
+ if event.input.id == "searchbar":
428
+ # Remove the bar when focus leaves
429
+ try:
430
+ event.input.remove()
431
+ except Exception:
432
+ pass
433
+
434
+ def action_search_next(self) -> None:
435
+ if not self.search_query:
436
+ self.action_search_prompt()
437
+ return
438
+ start = (self.selected + 1) % len(self.cells)
439
+ idx = self._search_from(start, +1)
440
+ if idx >= 0:
441
+ self._update_selection(idx)
442
+ if self.visual_mode:
443
+ self._update_visual_flags()
444
+ self.notify(f"Found at cell {idx + 1}")
445
+ else:
446
+ self.notify("No match")
447
+
448
+ def action_search_prev(self) -> None:
449
+ if not self.search_query:
450
+ self.action_search_prompt()
451
+ return
452
+ start = (self.selected - 1) % len(self.cells)
453
+ idx = self._search_from(start, -1)
454
+ if idx >= 0:
455
+ self._update_selection(idx)
456
+ if self.visual_mode:
457
+ self._update_visual_flags()
458
+ self.notify(f"Found at cell {idx + 1}")
459
+ else:
460
+ self.notify("No match")
461
+
462
+ # --- Go to first/last (gg / G) ---
463
+ def action_go_last(self) -> None:
464
+ last = len(self._cell_views) - 1
465
+ self._update_selection(last)
466
+ if self.visual_mode:
467
+ self._update_visual_flags()
468
+
469
+ def _g_timeout(self) -> None:
470
+ self._g_pending = False
471
+ self._g_timer = None
472
+
473
+ def action_go_prefix(self) -> None:
474
+ # Handle the "gg" sequence with a short timeout
475
+ if self._g_pending:
476
+ if self._g_timer is not None:
477
+ try:
478
+ self._g_timer.pause()
479
+ except Exception:
480
+ pass
481
+ self._g_timer = None
482
+ self._g_pending = False
483
+ self._update_selection(0)
484
+ if self.visual_mode:
485
+ self._update_visual_flags()
486
+ return
487
+ # Arm pending state and timer
488
+ self._g_pending = True
489
+ try:
490
+ self._g_timer = self.set_timer(0.7, self._g_timeout)
491
+ except Exception:
492
+ self._g_timer = None
493
+
494
+ def action_toggle_focus(self) -> None:
495
+ if self.visual_mode:
496
+ # In visual mode we always operate on input text; ignore toggling
497
+ self.notify(
498
+ "Visual mode: focus locked to Input", severity="information"
499
+ )
500
+ return
501
+ cv = self._cell_views[self.selected]
502
+ # Only allow switching to output if there is text output
503
+ if cv.focus_region == "input":
504
+ txt = _cell_output_text(cv.cell)
505
+ if not txt.strip():
506
+ # No output available; stay on input (optional hint)
507
+ self.notify("No text output for this cell", severity="warning")
508
+ return
509
+ cv.focus_region = "output"
510
+ else:
511
+ cv.focus_region = "input"
512
+ cv.refresh()
513
+
514
+ def action_copy(self) -> None:
515
+ # If in visual mode, copy all selected inputs
516
+ if self.visual_mode:
517
+ a, b = sorted((self.visual_anchor, self.selected))
518
+ pieces: list[str] = []
519
+ count = 0
520
+ for i in range(a, b + 1):
521
+ cell = self.cells[i]
522
+ if cell.get("cell_type") != "code":
523
+ continue
524
+ src = _cell_input_text(cell)
525
+ if src.strip():
526
+ pieces.append(src.rstrip("\n"))
527
+ count += 1
528
+ text = "\n\n".join(pieces)
529
+ if not text:
530
+ self.notify("No code in selection", severity="warning")
531
+ return
532
+ ok, msg = _copy_to_clipboard(text)
533
+ self.notify(f"Copied {count} cell(s): {msg}")
534
+ # Exit visual mode after yank
535
+ self._set_visual(False)
536
+ return
537
+
538
+ # Normal single-cell copy of focused region
539
+ cv = self._cell_views[self.selected]
540
+ cell = cv.cell
541
+ if cv.focus_region == "input":
542
+ text = _cell_input_text(cell)
543
+ else:
544
+ text = _cell_output_text(cell)
545
+ if not text.strip():
546
+ self.notify("No text output to copy", severity="warning")
547
+ return
548
+ ok, msg = _copy_to_clipboard(text)
549
+ self.notify(msg)
550
+
551
+ def action_toggle_outputs(self) -> None:
552
+ # Toggle global hide/show for outputs
553
+ self.hide_outputs = not self.hide_outputs
554
+ for cv in self._cell_views:
555
+ cv.outputs_hidden = self.hide_outputs
556
+ cv.refresh()
557
+
558
+ def _update_selection(
559
+ self, new_index: int, ensure_visible: bool = True
560
+ ) -> None:
561
+ self._cell_views[self.selected].selected = False
562
+ self._cell_views[self.selected].refresh()
563
+ self.selected = new_index
564
+ sel = self._cell_views[self.selected]
565
+ sel.selected = True
566
+ sel.refresh()
567
+ # scroll into view
568
+ if ensure_visible:
569
+ self.call_after_refresh(lambda: sel.scroll_visible())
570
+
571
+ def _update_visual_flags(self) -> None:
572
+ # Mark cells in visual range
573
+ for i, cv in enumerate(self._cell_views):
574
+ in_range = self.visual_mode and (
575
+ min(self.visual_anchor, self.selected)
576
+ <= i
577
+ <= max(self.visual_anchor, self.selected)
578
+ )
579
+ cv.in_visual = in_range
580
+ cv.refresh()
581
+
582
+ def _set_visual(self, on: bool) -> None:
583
+ self.visual_mode = on
584
+ if on:
585
+ self.visual_anchor = self.selected
586
+ # Update flags
587
+ self._update_visual_flags()
588
+ # Ensure focus is Input in visual mode
589
+ if on:
590
+ self._cell_views[self.selected].focus_region = "input"
591
+ else:
592
+ # Clear markers
593
+ for cv in self._cell_views:
594
+ cv.in_visual = False
595
+ cv.refresh()
596
+
597
+ def action_toggle_visual(self) -> None:
598
+ self._set_visual(not self.visual_mode)
599
+ self.notify("Visual mode" if self.visual_mode else "Visual off")
600
+
601
+ def run_textual_app(cells: list[dict[str, Any]]) -> int:
602
+ ensure_textual_available()
603
+ app = NotebookApp(cells)
604
+ app.run()
605
+ return 0
606
+ else:
607
+
608
+ def run_textual_app(cells: list[dict[str, Any]]) -> int: # pragma: no cover
609
+ ensure_textual_available()
610
+ return 1
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: nbsee
3
+ Version: 0.1.0
4
+ Summary: Rich/Textual terminal viewer for Jupyter notebooks focused on copying code and text outputs
5
+ Author: Yoga Yudistira
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/AnakTeka/nbsee
8
+ Project-URL: Repository, https://github.com/AnakTeka/nbsee
9
+ Project-URL: Issues, https://github.com/AnakTeka/nbsee/issues
10
+ Keywords: jupyter,notebook,cli,tui,rich,textual
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3 :: Only
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Environment :: Console
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: rich>=13.7
22
+ Requires-Dist: textual>=0.58
23
+ Dynamic: license-file
24
+
25
+ nbsee
26
+ =====
27
+
28
+ Simple CLI viewer for Jupyter notebooks focused on fast reading and easy copying of code and text outputs from the terminal.
29
+
30
+ Status: planning/design. See docs/TODO.md for the working task list.
31
+
32
+ Goals (MVP)
33
+ - Interactive terminal viewer for `.ipynb`.
34
+ - Navigate cells with keys; focus input vs output.
35
+ - Code cells: show source and text/plain outputs only.
36
+ - Easy copying from the viewer using the keyboard (xclip).
37
+ - Ignore images/HTML-rich outputs for now.
38
+
39
+ Quick Concept (not implemented yet)
40
+ - `nbsee notebook.ipynb` — open an interactive viewer.
41
+
42
+ Key bindings (proposed)
43
+ - `j`/`k`: move to next/previous cell
44
+ - Arrow Up/Down: scroll the view line-by-line
45
+ - PageUp/PageDown: page scroll
46
+ - Ctrl+u / Ctrl+d: half-page scroll up/down
47
+ - `Tab`: toggle focus between input (code/markdown) and output (text/plain)
48
+ - If a cell has no text output, `Tab` does nothing and shows a brief message.
49
+ - `y`: copy the currently focused region to clipboard using `xclip -selection clipboard`
50
+ - `z`: hide/show all outputs (fold outputs)
51
+ - `Shift+V`: start/stop visual selection for inputs; use `j`/`k` to expand/shrink; `y` copies all selected code cells
52
+ - `/`: search (inputs+outputs). Enter to submit, `Esc` cancels
53
+ - `n` / `N`: next/prev match
54
+ - `gg` / `G`: jump to first / last cell
55
+ - `G`/`gg`: jump to last/first cell (optional)
56
+ - `q`: quit
57
+
58
+ Notes
59
+ - Copying uses Linux X11 clipboard via `xclip`. Wayland/macOS not targeted in MVP.
60
+ - When an output is not available (e.g., no text/plain), copy will show an inline message.
61
+ - Python syntax highlighting is built-in via Rich.
62
+
63
+ Install (PyPI)
64
+ - `python -m pip install nbsee`
65
+ - Run: `nbsee path/to/notebook.ipynb`
66
+
67
+ Development (uv)
68
+ - Quick run without installing: `uv run -m nbsee path/to/notebook.ipynb`
69
+ - Run tests: `uv run pytest`
70
+ - Lint/format check: `uv run ruff check .` and `uv run ruff format --check .`
71
+ - Build distributions: `uv build`
72
+ - Release: push a tag like `v0.1.0` to trigger `.github/workflows/publish.yml` (requires PyPI trusted publishing setup).
73
+ - Install editable and use `nbsee` command:
74
+ - Create venv: `uv venv`
75
+ - Activate: `source .venv/bin/activate`
76
+ - Install: `uv pip install -e .`
77
+ - Run: `nbsee path/to/notebook.ipynb`
78
+ - Clipboard requirement: `xclip` on Linux/X11 must be installed and available in `PATH`.
79
+
80
+
81
+ Docs
82
+ - Design: `docs/DESIGN.md`
83
+ - TODOs & roadmap: `docs/TODO.md`
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ pyproject.toml
5
+ docs/DESIGN.md
6
+ docs/TODO.md
7
+ src/nbsee/__init__.py
8
+ src/nbsee/__main__.py
9
+ src/nbsee/textual_app.py
10
+ src/nbsee.egg-info/PKG-INFO
11
+ src/nbsee.egg-info/SOURCES.txt
12
+ src/nbsee.egg-info/dependency_links.txt
13
+ src/nbsee.egg-info/entry_points.txt
14
+ src/nbsee.egg-info/requires.txt
15
+ src/nbsee.egg-info/top_level.txt
16
+ tests/test_notebook_parsing.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nbsee = nbsee.__main__:main
@@ -0,0 +1,2 @@
1
+ rich>=13.7
2
+ textual>=0.58
@@ -0,0 +1 @@
1
+ nbsee
@@ -0,0 +1,45 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from nbsee.__main__ import _as_text, _cell_input_text, _cell_output_text, _load_notebook
6
+
7
+
8
+ def test_as_text_handles_none_and_lists():
9
+ assert _as_text(None) == ""
10
+ assert _as_text(["a", "b", 3]) == "ab3"
11
+ assert _as_text("x") == "x"
12
+
13
+
14
+ def test_cell_input_text_reads_source():
15
+ assert _cell_input_text({"source": "print(1)\n"}) == "print(1)\n"
16
+ assert _cell_input_text({"source": ["a\n", "b\n"]}) == "a\nb\n"
17
+
18
+
19
+ def test_cell_output_text_supports_text_plain_stream_and_error():
20
+ cell = {
21
+ "cell_type": "code",
22
+ "outputs": [
23
+ {"output_type": "execute_result", "data": {"text/plain": ["1", "\n"]}},
24
+ {"output_type": "stream", "name": "stdout", "text": ["hello", "\n"]},
25
+ {"output_type": "error", "traceback": ["Traceback...", "\n", "Boom"]},
26
+ ],
27
+ }
28
+ assert _cell_output_text(cell) == "1\n\nhello\n\nTraceback...\nBoom"
29
+
30
+
31
+ def test_cell_output_text_ignores_non_code_cells():
32
+ assert (
33
+ _cell_output_text({"cell_type": "markdown", "outputs": [{"text": "x"}]}) == ""
34
+ )
35
+
36
+
37
+ def test_load_notebook_requires_cells_list(tmp_path):
38
+ good = tmp_path / "good.ipynb"
39
+ good.write_text(json.dumps({"cells": [{"cell_type": "markdown", "source": "hi"}]}))
40
+ assert _load_notebook(str(good)) == [{"cell_type": "markdown", "source": "hi"}]
41
+
42
+ bad = tmp_path / "bad.ipynb"
43
+ bad.write_text(json.dumps({"metadata": {}}))
44
+ with pytest.raises(ValueError, match="missing cells"):
45
+ _load_notebook(str(bad))