Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions docs/usage/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,59 @@ When a code cell's source changes, its saved outputs and execution count are cle
Other cells are preserved, and no cells are executed. Writing a whole notebook without
`--cell` is not supported.

Use `execute` to run a notebook locally or on a Jupyter server.
Add `--cell CELL_ID` to run only one code cell:

```bash
jpterm execute notebook.ipynb
jpterm execute notebook.ipynb --cell CELL_ID
jpterm --server http://127.0.0.1:8000 execute notebook.ipynb
jpterm --server http://127.0.0.1:8000 execute notebook.ipynb --cell CELL_ID
jpterm --server http://127.0.0.1:8000 execute notebook.ipynb --cell CELL_ID --json
```

Local execution starts a detached kernel in the notebook's directory and reuses
it on later calls. Each notebook's resolved absolute path identifies its kernel.
The local TUI also reuses this session. It leaves an existing kernel running on
exit, and shuts down kernels it started itself.
Avoid simultaneous jpterm calls for the same notebook; execution and notebook
saves are not coordinated between CLI processes.

Manage detached local kernels with:

```bash
jpterm kernel start notebook.ipynb
jpterm kernel list
jpterm kernel list --json
jpterm kernel stop notebook.ipynb
```

Add `--server` to manage notebook kernels on a Jupyter server:

```bash
jpterm --server http://127.0.0.1:8000 kernel start notebook.ipynb
jpterm --server http://127.0.0.1:8000 kernel list
jpterm --server http://127.0.0.1:8000 kernel list --json
jpterm --server http://127.0.0.1:8000 kernel stop notebook.ipynb
```

`kernel start` uses the notebook's kernelspec (or `--kernel NAME` for a new
kernel) and waits until the kernel is ready. It reuses an existing live kernel
without executing cells or saving the notebook. `execute` still starts a kernel
automatically when needed.

Remote listing shows notebook sessions, including those started by other clients.
Paths are relative to the server's root. Remote `stop` deletes the matching
notebook session and lets the server manage kernel shutdown. Both backends
support `--json` for listing; remote entries include kernel and session IDs,
while local entries include the kernel or launcher PID.

With `--server`, execution reuses the notebook's session, creating
one with the notebook's kernelspec (or `--kernel NAME`) when needed. The kernel
stays running so variables and imports persist between calls. Without `--cell`,
code cells execute in notebook order.
Execution stops at the first cell error and returns a nonzero exit status.

For a server with real-time collaboration enabled, add `--collaborative` before
the command to read and write the live shared document:

Expand Down
16 changes: 16 additions & 0 deletions docs/usage/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,22 @@ $ jpterm --help

The easiest way to launch jpterm is simply by entering `jpterm` and hitting *Enter*. Jpterm will run locally on your machine, without needing a Jupyter server. This is the TUI mode (Text User Interface), where jpterm takes control of your terminal and runs as a full-screen application. There is also a [CLI](CLI.md) mode (Command Line Interface).

In local mode, opening a notebook reuses its running jpterm kernel when one
exists. For example, you can prepare a persistent kernel with
`jpterm kernel start notebook.ipynb`, then open that notebook in the TUI.

Press `Ctrl+W` to close the active tab. If a document has unsaved changes,
choose **Cancel** to keep editing or **Discard changes** to close it without
saving. You can cancel, press `Ctrl+S` to save, then close the tab.

Closing a notebook tab leaves its kernel running until it is stopped explicitly
or cleaned up when the TUI exits.

On exit, the TUI shuts down local notebook kernels it started itself. Kernels
that were already running remain available after the TUI exits. Use
`jpterm kernel stop notebook.ipynb` to stop a persistent kernel explicitly. Avoid simultaneous execution or saves
from the TUI and CLI for the same notebook; they are not coordinated.

Jpterm can also run as a client to a Jupyter server, just like when you use JupyterLab. While JupyterLab is a Web client that runs in the browser, jpterm runs in the terminal. The two main Jupyter servers are [jupyter-server](https://github.com/jupyter-server/jupyter_server) and [jupyverse](https://github.com/jupyter-server/jupyverse). You can install and launch them like so:

```bash
Expand Down
3 changes: 3 additions & 0 deletions docs/usage/key_bindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ Depending on the context, some commands can be triggered using key bindings. For

- [Notebook editor](../plugins/notebook_editor.md#key-bindings)
- [Console](../plugins/console.md#key-bindings)

In the TUI, `Ctrl+W` closes the active tab, with confirmation for unsaved changes.
Use `Ctrl+S` to save a text file or notebook before closing it.
34 changes: 25 additions & 9 deletions plugins/cell/txl_cell/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,27 @@ def __init__(
self.show_border = show_border
self.show_cell_id = show_cell_id
self.outputs = []
self._view_tasks = []
self._model_subscriptions = []
self.update(mount=False)
self.ycell.observe_deep(self.on_change)
self._cell_subscription = self.ycell.observe_deep(self.on_change)
self.styles.height = "auto"
self.cell_change_events = StapledObjectStream(
*create_memory_object_stream[Any](max_buffer_size=math.inf)
)
self.widget_change_events = StapledObjectStream(
*create_memory_object_stream[Any](max_buffer_size=math.inf)
)
task_group.create_task(self.observe_cell_changes())
task_group.create_task(self.observe_widget_changes())
self._view_tasks.append(task_group.create_task(self.observe_cell_changes()))
self._view_tasks.append(task_group.create_task(self.observe_widget_changes()))

async def on_unmount(self):
self.ycell.unobserve(self._cell_subscription)
for text, subscription in self._model_subscriptions:
text.unobserve(subscription)
await self.source.stop()
for task in self._view_tasks:
task.cancel()

def on_click(self):
self.clicked = True
Expand Down Expand Up @@ -159,13 +169,16 @@ async def observe_cell_changes(self):
# this is a widget
is_widget = True
room_id = f"ywidget:{inserted.guid}"
self.task_group.create_task(
self._view_tasks.append(self.task_group.create_task(
self.contents.websocket_provider(room_id, inserted),
)
))
inserted["_model_name"] = model_name = Text()
model_name.observe(partial(
subscription = model_name.observe(partial(
self.on_widget_change, inserted
))
self._model_subscriptions.append(
(model_name, subscription)
)
else:
#output = json.loads(str(inserted))
output = inserted
Expand Down Expand Up @@ -222,7 +235,7 @@ def update(self, mount: bool = True):
language=language,
show_border=self.show_border,
)
self.task_group.create_task(self.source.start())
self._view_tasks.append(self.task_group.create_task(self.source.start()))
if mount:
self.mount(self.source)

Expand All @@ -238,9 +251,12 @@ def get_output_widget(self, output):
guid = output["guid"]
ywidget_doc = Doc()
room_id = f"ywidget:{guid}"
self.task_group.create_task(self.contents.websocket_provider(room_id, ywidget_doc))
self._view_tasks.append(self.task_group.create_task(
self.contents.websocket_provider(room_id, ywidget_doc)
))
ywidget_doc["_model_name"] = model_name = Text()
model_name.observe(partial(self.on_widget_change, ywidget_doc))
subscription = model_name.observe(partial(self.on_widget_change, ywidget_doc))
self._model_subscriptions.append((model_name, subscription))
return

output_type = output["output_type"]
Expand Down
52 changes: 52 additions & 0 deletions plugins/jpterm/txl_jpterm/close_tab.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Static


class ConfirmCloseTab(ModalScreen[bool]):
DEFAULT_CSS = """
ConfirmCloseTab {
align: center middle;
background: $background 60%;
}
#close-tab-dialog {
width: 60;
max-width: 90%;
height: auto;
padding: 1 2;
border: thick $accent;
background: $surface;
}
#close-tab-buttons {
height: 3;
margin-top: 1;
align: right middle;
}
#close-tab-buttons Button {
margin-left: 1;
min-width: 0;
padding: 0 1;
}
"""
BINDINGS = [("escape", "cancel", "Cancel")]

def __init__(self, title):
super().__init__()
self.tab_title = title

def compose(self) -> ComposeResult:
with Vertical(id="close-tab-dialog"):
yield Static(f"Close {self.tab_title}?\nUnsaved changes will be lost.", markup=False)
with Horizontal(id="close-tab-buttons"):
yield Button("Cancel", id="keep-editing")
yield Button("Discard changes", variant="error", id="discard-changes")

def on_mount(self):
self.query_one("#keep-editing", Button).focus()

def action_cancel(self):
self.dismiss(False)

def on_button_pressed(self, event: Button.Pressed):
self.dismiss(event.button.id == "discard-changes")
19 changes: 19 additions & 0 deletions plugins/jpterm/txl_jpterm/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
from fps import Module
from textual._context import active_app
from textual.app import App
from textual.binding import Binding
from textual.containers import Container
from textual.reactive import var

from txl.base import Editors, FileBrowser, Footer, Header, Launcher, MainArea

from .close_tab import ConfirmCloseTab
from .footer import Footer as _Footer
from .header import Header as _Header
from .main_area import MainArea as _MainArea
Expand All @@ -18,6 +20,7 @@ class Jpterm(App):
("f", "toggle_files", "Toggle Files"),
("l", "open_launcher", "Open Launcher"),
("q", "quit", "Quit"),
Binding("ctrl+w", "close_tab", "Close Tab", priority=True),
]
show_browser = var(True)
cli: bool = False
Expand Down Expand Up @@ -54,6 +57,22 @@ def action_toggle_files(self) -> None:
def action_open_launcher(self) -> None:
self.main_area.show(self.launcher, "Launcher")

async def action_close_tab(self) -> None:
if self.cli or isinstance(self.screen, ConfirmCloseTab):
return
widget = self.main_area.shown
if widget not in self.main_area.widget_to_tab:
return
tab, dirty = self.main_area.widget_to_tab[widget]
if dirty:
async def confirm(discard):
if discard:
await self.main_area.close(widget)

self.push_screen(ConfirmCloseTab(tab.label_text.removeprefix("+ ")), confirm)
else:
await self.main_area.close(widget)


class JptermModule(Module):
def __init__(self, name: str, driver_class=None):
Expand Down
30 changes: 27 additions & 3 deletions plugins/jpterm/txl_jpterm/main_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,19 +28,41 @@ def show(self, widget: Widget, title: Optional[str] = None, mount: bool = True):
if self.tabs is None:
self.tabs = Tabs(tab)
self.mount(self.tabs)
self.mount(widget)
mount = False
else:
self.tabs.add_tab(tab)
self.tabs.active = tab.id
self.widget_to_tab[widget] = (tab, False)
self.mounted.append(widget)
if mount:
if widget.parent is None:
self.mount(widget)
else:
tab, dirty = self.widget_to_tab[widget]
self.tabs.active = tab.id

async def close(self, widget=None):
widget = self.shown if widget is None else widget
if widget not in self.widget_to_tab:
return
launcher = getattr(self.app, "launcher", None)
if widget is launcher and len(self.mounted) == 1:
return
index = self.mounted.index(widget)
tab, _ = self.widget_to_tab.pop(widget)
self.mounted.remove(widget)
was_shown = self.shown is widget
if was_shown:
self.shown = None
await self.tabs.remove_tab(tab)
if widget is launcher:
widget.display = False
else:
await widget.remove()
if was_shown:
if self.mounted:
self.show(self.mounted[max(0, index - 1)])
elif launcher is not None:
self.show(launcher, "Launcher")

def get_label(self) -> str:
tab = self.tabs.active_tab
return tab.label_text
Expand Down Expand Up @@ -70,6 +92,8 @@ def clear_dirty(self, widget: Widget) -> None:
def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
widgets = list(self.widget_to_tab.keys())
tabs = [tab for tab, dirty in self.widget_to_tab.values()]
if event.tab not in tabs:
return
widget = widgets[tabs.index(event.tab)]
if self.shown is not None:
self.shown.display = False
Expand Down
2 changes: 1 addition & 1 deletion plugins/local_kernels/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ requires-python = ">=3.11"
license = "MIT"
keywords = []
classifiers = [ "Development Status :: 4 - Beta", "Programming Language :: Python", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3.15", "Programming Language :: Python :: Implementation :: CPython", "Programming Language :: Python :: Implementation :: PyPy",]
dependencies = [ "txl ==0.3.4", "txl_kernel >=0.3.11,<0.4.0", "pyzmq >=27.1.0", "ipykernel",]
dependencies = [ "txl ==0.3.4", "txl_kernel >=0.3.11,<0.4.0", "pyzmq >=27.1.0", "ipykernel", "psutil >=5.8",]
[[project.authors]]
name = "David Brochart"
email = "david.brochart@gmail.com"
Expand Down
23 changes: 13 additions & 10 deletions plugins/local_kernels/txl_local_kernels/connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ async def launch_kernel(
kernel_cwd: str | None,
capture_output: bool,
) -> Process:
cmd = kernel_command(kernelspec_path, connection_file_path)
if capture_output:
stdout = subprocess.DEVNULL
stderr = subprocess.STDOUT
else:
stdout = None
stderr = None
process = await open_process(cmd, stdout=stdout, stderr=stderr, cwd=kernel_cwd or None)
return process


def kernel_command(kernelspec_path: str, connection_file_path: str) -> list[str]:
with open(kernelspec_path) as f:
kernelspec = json.load(f)
cmd = [s.format(connection_file=connection_file_path) for s in kernelspec["argv"]]
Expand All @@ -87,16 +99,7 @@ async def launch_kernel(
"python%i.%i" % sys.version_info[:2],
}:
cmd[0] = sys.executable
if capture_output:
stdout = subprocess.DEVNULL
stderr = subprocess.STDOUT
else:
stdout = None
stderr = None
if not kernel_cwd:
kernel_cwd = None
process = await open_process(cmd, stdout=stdout, stderr=stderr, cwd=kernel_cwd)
return process
return cmd

def create_socket(channel: str, cfg: cfg_t, identity: Optional[bytes] = None) -> Socket:
ip = cfg["ip"]
Expand Down
Loading
Loading