terminux 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.
@@ -0,0 +1,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,34 @@
1
+ .PHONY: all test build format check lint clean
2
+
3
+ all: test lint
4
+
5
+ check: lint
6
+
7
+ lint:
8
+ uv run --active ruff check
9
+ uv run --active ruff format --check
10
+ uv run --active ty check src
11
+ uv run --active pyrefly check src
12
+ uv run --active mypy src
13
+
14
+ format:
15
+ uv run --active ruff format src tests
16
+ uv run --active ruff check src tests --fix
17
+ uv run --active ruff format src tests
18
+
19
+ test:
20
+ uv run pytest
21
+
22
+ test-cov:
23
+ uv run pytest --cov=terminux --cov-report=html --cov-report=term tests
24
+
25
+ clean:
26
+ rm -rf .pytest_cache .ruff_cache dist build __pycache__ .mypy_cache \
27
+ .coverage htmlcov .coverage.* *.egg-info
28
+ adt clean
29
+
30
+ build: clean
31
+ uv build
32
+
33
+ publish: build
34
+ uv publish
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: terminux
3
+ Version: 0.1.0
4
+ Summary: A cross-platform desktop terminal with workspaces and tabs
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: platformdirs>=4.0
7
+ Requires-Dist: ptyprocess>=0.7
8
+ Requires-Dist: pywebview>=5.0
9
+ Requires-Dist: starlette>=0.40
10
+ Requires-Dist: uvicorn>=0.30
11
+ Requires-Dist: websockets>=13.0
File without changes
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "terminux"
3
+ version = "0.1.0"
4
+ description = "A cross-platform desktop terminal with workspaces and tabs"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "pywebview>=5.0",
9
+ "starlette>=0.40",
10
+ "uvicorn>=0.30",
11
+ "ptyprocess>=0.7",
12
+ "platformdirs>=4.0",
13
+ "websockets>=13.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ terminux = "terminux.app:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/terminux"]
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "mypy>=2.1.0",
29
+ "nox>=2026.4.10",
30
+ "pre-commit>=4.6.0",
31
+ "pyrefly>=1.0.0",
32
+ "pytest>=9.0.3",
33
+ "pytest-cov>=7.1.0",
34
+ "ruff>=0.15.13",
35
+ "ty>=0.0.37",
36
+ ]
37
+
38
+ [tool.pytest.ini_options]
39
+ testpaths = ["tests"]
40
+ markers = [
41
+ "unit: fast isolated unit tests",
42
+ "integration: component-interaction tests",
43
+ "e2e: end-to-end workflow tests",
44
+ ]
45
+ filterwarnings = [
46
+ # Inherent to spawning a PTY from our multi-threaded process (ptyprocess
47
+ # uses pty.fork, then execs immediately); safe here, not actionable.
48
+ "ignore:This process .* is multi-threaded, use of forkpty.*:DeprecationWarning",
49
+ ]
50
+
51
+ [tool.mypy]
52
+ strict = true
53
+
54
+ [[tool.mypy.overrides]]
55
+ module = ["ptyprocess", "webview", "webview.*"]
56
+ ignore_missing_imports = true
57
+
58
+ [tool.coverage.run]
59
+ branch = true
60
+ source = ["terminux"]
61
+
62
+ [tool.coverage.report]
63
+ show_missing = true
64
+ exclude_lines = [
65
+ "pragma: no cover",
66
+ "if TYPE_CHECKING:",
67
+ "raise NotImplementedError",
68
+ "if __name__ == .__main__.:",
69
+ "\\.\\.\\.",
70
+ ]
71
+
72
+ [tool.pyrefly]
73
+ preset = "legacy"
74
+ ignore-missing-imports = [
75
+ "ptyprocess",
76
+ "webview",
77
+ "webview.*",
78
+ ]
@@ -0,0 +1,5 @@
1
+ """terminux — a cross-platform desktop terminal with workspaces and tabs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """``python -m terminux`` → launch the app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from terminux.app import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -0,0 +1,123 @@
1
+ """Entrypoint: run the loopback server, then host it in a pywebview window.
2
+
3
+ ``--no-window`` runs the server headless (browse to the printed URL) — used
4
+ for development and e2e tests, and a preview of the future "web mode".
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import contextlib
11
+ import logging
12
+ import socket
13
+ import threading
14
+ import time
15
+ from typing import cast
16
+
17
+ import uvicorn
18
+
19
+ from terminux.server.asgi import AppController, build_app
20
+ from terminux.server.auth import SESSION_TOKEN
21
+
22
+ log = logging.getLogger(__name__)
23
+ HOST = "127.0.0.1"
24
+
25
+
26
+ def _free_port() -> int:
27
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
28
+ s.bind((HOST, 0))
29
+ return int(s.getsockname()[1])
30
+
31
+
32
+ def _serve(server: uvicorn.Server) -> None:
33
+ server.run()
34
+
35
+
36
+ def main() -> None:
37
+ logging.basicConfig(
38
+ level=logging.INFO, format="%(levelname)s %(name)s: %(message)s"
39
+ )
40
+ parser = argparse.ArgumentParser(prog="terminux")
41
+ parser.add_argument(
42
+ "--no-window",
43
+ action="store_true",
44
+ help="run the server only (no desktop window)",
45
+ )
46
+ parser.add_argument("--port", type=int, default=0, help="bind port (0 = ephemeral)")
47
+ args = parser.parse_args()
48
+
49
+ port = args.port or _free_port()
50
+ app = build_app(persist=True)
51
+ config = uvicorn.Config(
52
+ app,
53
+ host=HOST,
54
+ port=port,
55
+ log_level="warning",
56
+ ws="websockets-sansio", # avoid the deprecated legacy websockets impl
57
+ )
58
+ server = uvicorn.Server(config)
59
+
60
+ thread = threading.Thread(target=_serve, args=(server,), daemon=True)
61
+ thread.start()
62
+ while not server.started:
63
+ time.sleep(0.02)
64
+
65
+ url = f"http://{HOST}:{port}/?t={SESSION_TOKEN}"
66
+ log.info("terminux server ready at %s", url)
67
+
68
+ if args.no_window:
69
+ with contextlib.suppress(KeyboardInterrupt):
70
+ thread.join()
71
+ return
72
+
73
+ _run_windowed(url, app.state.controller, server)
74
+
75
+
76
+ def _run_windowed(url: str, ctl: AppController, server: uvicorn.Server) -> None:
77
+ import webview # noqa: PLC0415 (heavy GUI import; only when windowing)
78
+ from webview.dom import DOMEventHandler # noqa: PLC0415
79
+
80
+ window = webview.create_window("terminux", url, width=1100, height=720)
81
+ if window is None:
82
+ msg = "failed to create application window"
83
+ raise RuntimeError(msg)
84
+
85
+ def _on_drop(event: dict[str, object]) -> None:
86
+ # WKWebView hides file paths from JS; pywebview injects the real path
87
+ # as ``pywebviewFullPath`` only for a Python drop handler (registering
88
+ # one also enables the native Cocoa path capture). Route them to the
89
+ # active terminal, shell-quoted — drop a file into Claude Code.
90
+ # ``event`` is pywebview's loosely-typed JSON event dict.
91
+ if not isinstance(event, dict):
92
+ return
93
+ transfer = event.get("dataTransfer")
94
+ files = (
95
+ cast("dict[str, object]", transfer).get("files")
96
+ if isinstance(transfer, dict)
97
+ else None
98
+ )
99
+ paths: list[str] = []
100
+ for f in files if isinstance(files, list) else []:
101
+ if isinstance(f, dict):
102
+ p = cast("dict[str, object]", f).get("pywebviewFullPath")
103
+ if isinstance(p, str):
104
+ paths.append(p)
105
+ if paths:
106
+ ctl.paste_paths(paths)
107
+
108
+ def _register_drop() -> None:
109
+ # Must run after the DOM is loaded; prevent_default stops the webview
110
+ # from navigating to the dropped file.
111
+ window.dom.document.on("drop", DOMEventHandler(_on_drop, prevent_default=True))
112
+
113
+ def _shutdown() -> None:
114
+ ctl.terminals.close_all()
115
+ server.should_exit = True
116
+
117
+ window.events.loaded += _register_drop
118
+ window.events.closed += _shutdown
119
+ webview.start()
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,3 @@
1
+ """Core domain: state model, persistence, and PTY-backed terminals."""
2
+
3
+ from __future__ import annotations
@@ -0,0 +1,291 @@
1
+ """In-memory authoritative app state: workspaces and tabs.
2
+
3
+ Only structure (ids, names, order, active selection, UI prefs) is persisted.
4
+ Live terminals are transient and rebuilt on demand; see ``core.terminal``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+
15
+ def _new_id() -> str:
16
+ return uuid.uuid4().hex
17
+
18
+
19
+ class WorkspaceStatus(str, Enum):
20
+ """Auto-derived sidebar status (functional spec §4.4)."""
21
+
22
+ ACTIVE = "active"
23
+ RUNNING = "running"
24
+ IDLE = "idle"
25
+ EXITED = "exited"
26
+
27
+
28
+ @dataclass
29
+ class Tab:
30
+ """One terminal session inside a workspace."""
31
+
32
+ id: str = field(default_factory=_new_id)
33
+ title: str = "shell"
34
+ user_set_title: bool = False
35
+ # Transient (never persisted):
36
+ terminal_id: str | None = None
37
+ has_unseen_output: bool = False
38
+ spawn_cwd: str | None = None # directory inherited from the previous tab
39
+
40
+ def to_json(self) -> dict[str, Any]:
41
+ return {
42
+ "id": self.id,
43
+ "title": self.title,
44
+ "user_set_title": self.user_set_title,
45
+ }
46
+
47
+ @classmethod
48
+ def from_json(cls, data: dict[str, Any]) -> Tab:
49
+ return cls(
50
+ id=str(data["id"]),
51
+ title=str(data.get("title", "shell")),
52
+ user_set_title=bool(data.get("user_set_title")),
53
+ )
54
+
55
+
56
+ @dataclass
57
+ class Workspace:
58
+ """A named container for a set of terminal tabs."""
59
+
60
+ id: str = field(default_factory=_new_id)
61
+ name: str = "workspace"
62
+ tab_ids: list[str] = field(default_factory=list)
63
+ active_tab_id: str | None = None
64
+ # Transient: set when a tab produces output while this workspace is not active.
65
+ has_unseen_output: bool = False
66
+
67
+ def to_json(self) -> dict[str, Any]:
68
+ return {
69
+ "id": self.id,
70
+ "name": self.name,
71
+ "tab_ids": list(self.tab_ids),
72
+ "active_tab_id": self.active_tab_id,
73
+ }
74
+
75
+ @classmethod
76
+ def from_json(cls, data: dict[str, Any]) -> Workspace:
77
+ return cls(
78
+ id=str(data["id"]),
79
+ name=str(data.get("name", "workspace")),
80
+ tab_ids=[str(t) for t in data.get("tab_ids", [])],
81
+ active_tab_id=(
82
+ str(data["active_tab_id"])
83
+ if data.get("active_tab_id") is not None
84
+ else None
85
+ ),
86
+ )
87
+
88
+
89
+ @dataclass
90
+ class UiPrefs:
91
+ """Persisted UI preferences."""
92
+
93
+ sidebar_width: int = 220
94
+ sidebar_collapsed: bool = False
95
+ font_size: int = 13
96
+
97
+ def to_json(self) -> dict[str, Any]:
98
+ return {
99
+ "sidebar_width": self.sidebar_width,
100
+ "sidebar_collapsed": self.sidebar_collapsed,
101
+ "font_size": self.font_size,
102
+ }
103
+
104
+ @classmethod
105
+ def from_json(cls, data: dict[str, Any]) -> UiPrefs:
106
+ return cls(
107
+ sidebar_width=int(data.get("sidebar_width", 220)),
108
+ sidebar_collapsed=bool(data.get("sidebar_collapsed")),
109
+ font_size=int(data.get("font_size", 13)),
110
+ )
111
+
112
+
113
+ SCHEMA_VERSION = 1
114
+
115
+
116
+ @dataclass
117
+ class AppState:
118
+ """Authoritative state. All mutation happens on the backend event loop."""
119
+
120
+ workspaces: list[Workspace] = field(default_factory=list)
121
+ tabs: dict[str, Tab] = field(default_factory=dict)
122
+ active_workspace_id: str | None = None
123
+ ui: UiPrefs = field(default_factory=UiPrefs)
124
+
125
+ # ----- construction -------------------------------------------------
126
+
127
+ @classmethod
128
+ def default(cls) -> AppState:
129
+ """A single workspace with one tab — the always-valid baseline."""
130
+ state = cls()
131
+ ws = state.add_workspace(name="workspace 1")
132
+ state.add_tab(ws.id)
133
+ state.active_workspace_id = ws.id
134
+ return state
135
+
136
+ # ----- workspace ops ------------------------------------------------
137
+
138
+ def add_workspace(self, name: str | None = None) -> Workspace:
139
+ ws = Workspace(name=name or self._next_workspace_name())
140
+ self.workspaces.append(ws)
141
+ return ws
142
+
143
+ def _next_workspace_name(self) -> str:
144
+ used = {w.name for w in self.workspaces}
145
+ i = 1
146
+ while f"workspace {i}" in used:
147
+ i += 1
148
+ return f"workspace {i}"
149
+
150
+ def get_workspace(self, ws_id: str) -> Workspace | None:
151
+ return next((w for w in self.workspaces if w.id == ws_id), None)
152
+
153
+ def remove_workspace(self, ws_id: str) -> list[str]:
154
+ """Remove a workspace; return the ids of its tabs (caller kills terminals)."""
155
+ ws = self.get_workspace(ws_id)
156
+ if ws is None:
157
+ return []
158
+ tab_ids = list(ws.tab_ids)
159
+ for tid in tab_ids:
160
+ self.tabs.pop(tid, None)
161
+ self.workspaces = [w for w in self.workspaces if w.id != ws_id]
162
+ if self.active_workspace_id == ws_id:
163
+ self.active_workspace_id = (
164
+ self.workspaces[0].id if self.workspaces else None
165
+ )
166
+ return tab_ids
167
+
168
+ def set_active_workspace(self, ws_id: str) -> None:
169
+ if self.get_workspace(ws_id) is None:
170
+ return
171
+ self.active_workspace_id = ws_id
172
+ ws = self.get_workspace(ws_id)
173
+ if ws is not None:
174
+ ws.has_unseen_output = False
175
+ for tid in ws.tab_ids:
176
+ tab = self.tabs.get(tid)
177
+ if tab is not None and tid == ws.active_tab_id:
178
+ tab.has_unseen_output = False
179
+
180
+ # ----- tab ops ------------------------------------------------------
181
+
182
+ def add_tab(
183
+ self,
184
+ ws_id: str,
185
+ title: str = "shell",
186
+ spawn_cwd: str | None = None,
187
+ ) -> Tab | None:
188
+ ws = self.get_workspace(ws_id)
189
+ if ws is None:
190
+ return None
191
+ tab = Tab(title=title, spawn_cwd=spawn_cwd)
192
+ self.tabs[tab.id] = tab
193
+ ws.tab_ids.append(tab.id)
194
+ ws.active_tab_id = tab.id
195
+ return tab
196
+
197
+ def remove_tab(self, tab_id: str) -> None:
198
+ for ws in self.workspaces:
199
+ if tab_id in ws.tab_ids:
200
+ ws.tab_ids.remove(tab_id)
201
+ if ws.active_tab_id == tab_id:
202
+ ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
203
+ self.tabs.pop(tab_id, None)
204
+
205
+ # ----- status -------------------------------------------------------
206
+
207
+ def workspace_status(self, ws_id: str) -> WorkspaceStatus:
208
+ ws = self.get_workspace(ws_id)
209
+ if ws is None:
210
+ return WorkspaceStatus.IDLE
211
+ if ws_id == self.active_workspace_id:
212
+ return WorkspaceStatus.ACTIVE
213
+ has_live = any(
214
+ (t := self.tabs.get(tid)) is not None and t.terminal_id is not None
215
+ for tid in ws.tab_ids
216
+ )
217
+ if not has_live and ws.tab_ids:
218
+ return WorkspaceStatus.EXITED
219
+ if ws.has_unseen_output:
220
+ return WorkspaceStatus.RUNNING
221
+ return WorkspaceStatus.IDLE
222
+
223
+ # ----- serialization ------------------------------------------------
224
+
225
+ def to_json(self) -> dict[str, Any]:
226
+ return {
227
+ "version": SCHEMA_VERSION,
228
+ "workspaces": [w.to_json() for w in self.workspaces],
229
+ "tabs": [t.to_json() for t in self.tabs.values()],
230
+ "active_workspace_id": self.active_workspace_id,
231
+ "ui": self.ui.to_json(),
232
+ }
233
+
234
+ @classmethod
235
+ def from_json(cls, data: dict[str, Any]) -> AppState:
236
+ tabs = {}
237
+ for raw in data.get("tabs", []):
238
+ tab = Tab.from_json(raw)
239
+ tabs[tab.id] = tab
240
+ workspaces = [Workspace.from_json(w) for w in data.get("workspaces", [])]
241
+ state = cls(
242
+ workspaces=workspaces,
243
+ tabs=tabs,
244
+ active_workspace_id=data.get("active_workspace_id"),
245
+ ui=UiPrefs.from_json(data.get("ui", {})),
246
+ )
247
+ state._repair()
248
+ return state
249
+
250
+ def _repair(self) -> AppState:
251
+ """Drop dangling references; guarantee >= 1 workspace with >= 1 tab."""
252
+ for ws in self.workspaces:
253
+ ws.tab_ids = [tid for tid in ws.tab_ids if tid in self.tabs]
254
+ if ws.active_tab_id not in ws.tab_ids:
255
+ ws.active_tab_id = ws.tab_ids[-1] if ws.tab_ids else None
256
+ if not ws.tab_ids:
257
+ tab = Tab()
258
+ self.tabs[tab.id] = tab
259
+ ws.tab_ids.append(tab.id)
260
+ ws.active_tab_id = tab.id
261
+ # drop orphan tabs not referenced by any workspace
262
+ referenced = {tid for ws in self.workspaces for tid in ws.tab_ids}
263
+ self.tabs = {tid: t for tid, t in self.tabs.items() if tid in referenced}
264
+ if not self.workspaces:
265
+ ws = self.add_workspace(name="workspace 1")
266
+ self.add_tab(ws.id)
267
+ if self.get_workspace(self.active_workspace_id or "") is None:
268
+ self.active_workspace_id = self.workspaces[0].id
269
+ return self
270
+
271
+ def view_json(self) -> dict[str, Any]:
272
+ """Snapshot for the frontend, including derived status."""
273
+ return {
274
+ "workspaces": [
275
+ {
276
+ **w.to_json(),
277
+ "status": self.workspace_status(w.id).value,
278
+ }
279
+ for w in self.workspaces
280
+ ],
281
+ "tabs": {
282
+ tid: {
283
+ **t.to_json(),
284
+ "live": t.terminal_id is not None,
285
+ "has_unseen_output": t.has_unseen_output,
286
+ }
287
+ for tid, t in self.tabs.items()
288
+ },
289
+ "active_workspace_id": self.active_workspace_id,
290
+ "ui": self.ui.to_json(),
291
+ }
@@ -0,0 +1,58 @@
1
+ """Atomic, versioned JSON persistence of structural state.
2
+
3
+ Live processes and scrollback are never persisted (functional spec §7):
4
+ a restored tab starts a fresh shell.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import json
11
+ import logging
12
+ import os
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ import platformdirs
17
+
18
+ from terminux.core.model import AppState
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+
23
+ def state_path() -> Path:
24
+ data_dir = Path(platformdirs.user_data_dir("terminux"))
25
+ data_dir.mkdir(parents=True, exist_ok=True)
26
+ return data_dir / "state.json"
27
+
28
+
29
+ def load_state(path: Path | None = None) -> AppState:
30
+ """Load persisted state, falling back to a sane default on any error."""
31
+ path = path or state_path()
32
+ try:
33
+ raw = json.loads(path.read_text(encoding="utf-8"))
34
+ return AppState.from_json(raw)
35
+ except FileNotFoundError:
36
+ log.info("no persisted state at %s; starting fresh", path)
37
+ except (json.JSONDecodeError, KeyError, TypeError, ValueError, OSError) as exc:
38
+ log.warning("could not load state from %s (%s); starting fresh", path, exc)
39
+ return AppState.default()
40
+
41
+
42
+ def save_state(state: AppState, path: Path | None = None) -> None:
43
+ """Write state atomically (temp file + os.replace)."""
44
+ path = path or state_path()
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ payload = json.dumps(state.to_json(), indent=2)
47
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=".state-", suffix=".tmp")
48
+ tmp_path = Path(tmp_name)
49
+ try:
50
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
51
+ fh.write(payload)
52
+ fh.flush()
53
+ os.fsync(fh.fileno())
54
+ tmp_path.replace(path)
55
+ except OSError:
56
+ log.exception("failed to persist state to %s", path)
57
+ with contextlib.suppress(OSError):
58
+ tmp_path.unlink(missing_ok=True)