terminux 0.1.0__py3-none-any.whl

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.
terminux/__init__.py ADDED
@@ -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"
terminux/__main__.py ADDED
@@ -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()
terminux/app.py ADDED
@@ -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
terminux/core/model.py ADDED
@@ -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)
@@ -0,0 +1,137 @@
1
+ """PTY backend abstraction.
2
+
3
+ Unix is implemented via ``ptyprocess``. Windows (``pywinpty``/ConPTY) is a
4
+ future addition behind this same Protocol — see technical spec §10.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import os
11
+ import shutil
12
+ import signal
13
+ import subprocess # noqa: S404 (fixed argv to `lsof`, no shell, resolved path)
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Protocol, cast, runtime_checkable
17
+
18
+
19
+ @runtime_checkable
20
+ class PtyBackend(Protocol):
21
+ """OS-agnostic PTY interface used by ``core.terminal``."""
22
+
23
+ @property
24
+ def fd(self) -> int: ...
25
+
26
+ def write(self, data: bytes) -> None: ...
27
+
28
+ def resize(self, cols: int, rows: int) -> None: ...
29
+
30
+ def is_alive(self) -> bool: ...
31
+
32
+ def terminate(self) -> None: ...
33
+
34
+ def exit_code(self) -> int | None: ...
35
+
36
+ def cwd(self) -> str | None: ...
37
+
38
+
39
+ class UnixPty:
40
+ """PTY-backed child process for POSIX systems."""
41
+
42
+ def __init__(self, argv: list[str], cwd: str, cols: int, rows: int) -> None:
43
+ from ptyprocess import PtyProcess # noqa: PLC0415 (optional/heavy import)
44
+
45
+ env = dict(os.environ)
46
+ env.setdefault("TERM", "xterm-256color")
47
+ env["TERMINUX"] = "1"
48
+ self._proc = PtyProcess.spawn(
49
+ argv,
50
+ cwd=cwd,
51
+ env=env,
52
+ dimensions=(rows, cols),
53
+ )
54
+
55
+ @property
56
+ def fd(self) -> int:
57
+ return int(self._proc.fd)
58
+
59
+ def write(self, data: bytes) -> None:
60
+ with contextlib.suppress(OSError):
61
+ os.write(self._proc.fd, data)
62
+
63
+ def resize(self, cols: int, rows: int) -> None:
64
+ with contextlib.suppress(OSError, ValueError):
65
+ self._proc.setwinsize(rows, cols)
66
+
67
+ def is_alive(self) -> bool:
68
+ return bool(self._proc.isalive())
69
+
70
+ def terminate(self) -> None:
71
+ """SIGHUP → SIGTERM → SIGKILL escalation (functional spec §10)."""
72
+ if not self._proc.isalive():
73
+ return
74
+ for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGKILL):
75
+ try:
76
+ self._proc.kill(sig)
77
+ except OSError:
78
+ return
79
+ if not self._proc.isalive():
80
+ return
81
+
82
+ def exit_code(self) -> int | None:
83
+ if self._proc.isalive():
84
+ return None
85
+ try:
86
+ self._proc.wait()
87
+ except Exception: # noqa: BLE001 (best-effort reap)
88
+ return None
89
+ return cast("int | None", self._proc.exitstatus)
90
+
91
+ def cwd(self) -> str | None:
92
+ """Best-effort current directory of the shell process.
93
+
94
+ Lets a new tab open where the previously active shell was (cmux
95
+ behaviour), without requiring shell-side OSC 7 configuration.
96
+ """
97
+ pid = self._proc.pid
98
+ if pid is None:
99
+ return None
100
+ if sys.platform == "linux":
101
+ return _linux_cwd(pid)
102
+ if sys.platform == "darwin":
103
+ return _darwin_cwd(pid)
104
+ return None
105
+
106
+
107
+ def _linux_cwd(pid: int) -> str | None:
108
+ with contextlib.suppress(OSError):
109
+ return str(Path(f"/proc/{pid}/cwd").readlink())
110
+ return None
111
+
112
+
113
+ def _darwin_cwd(pid: int) -> str | None:
114
+ lsof = shutil.which("lsof")
115
+ if lsof is None:
116
+ return None
117
+ try:
118
+ result = subprocess.run( # noqa: S603 (fixed argv, resolved path, no shell)
119
+ [lsof, "-a", "-d", "cwd", "-p", str(pid), "-Fn"],
120
+ capture_output=True,
121
+ text=True,
122
+ timeout=2,
123
+ check=False,
124
+ )
125
+ except (OSError, subprocess.SubprocessError):
126
+ return None
127
+ for line in result.stdout.splitlines():
128
+ if line.startswith("n"):
129
+ return line[1:]
130
+ return None
131
+
132
+
133
+ def spawn_pty(argv: list[str], cwd: str, cols: int, rows: int) -> PtyBackend:
134
+ if sys.platform == "win32":
135
+ msg = "Windows PTY backend is not implemented yet (technical spec §10)."
136
+ raise NotImplementedError(msg)
137
+ return UnixPty(argv, cwd, cols, rows)
@@ -0,0 +1,33 @@
1
+ """Resolve the default shell and starting directory per OS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def default_shell() -> list[str]:
12
+ """Return the argv for a fresh interactive login shell."""
13
+ if sys.platform == "win32":
14
+ for candidate in ("pwsh.exe", "powershell.exe", "cmd.exe"):
15
+ found = shutil.which(candidate)
16
+ if found:
17
+ return [found]
18
+ return ["cmd.exe"]
19
+ shell = os.environ.get("SHELL")
20
+ if shell and Path(shell).exists():
21
+ return [shell, "-l"]
22
+ for candidate in ("/bin/zsh", "/bin/bash", "/bin/sh"):
23
+ if Path(candidate).exists():
24
+ return [candidate, "-l"]
25
+ return ["/bin/sh"]
26
+
27
+
28
+ def default_cwd() -> str:
29
+ """Starting directory for a new terminal (home, with cwd fallback)."""
30
+ home = Path.home()
31
+ if home.is_dir():
32
+ return str(home)
33
+ return str(Path.cwd())