myrtui 0.5.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.
Files changed (65) hide show
  1. myrtui/__init__.py +3 -0
  2. myrtui/cli.py +522 -0
  3. myrtui/client/__init__.py +1 -0
  4. myrtui/client/app.py +3068 -0
  5. myrtui/client/cells.py +31 -0
  6. myrtui/client/clipboard.py +238 -0
  7. myrtui/client/context_menu.py +98 -0
  8. myrtui/client/docpage.py +808 -0
  9. myrtui/client/file_tree.py +440 -0
  10. myrtui/client/git_panel.py +345 -0
  11. myrtui/client/graphics.py +251 -0
  12. myrtui/client/htmlexport_data/KATEX_VERSION +1 -0
  13. myrtui/client/htmlexport_data/auto-render.min.js +1 -0
  14. myrtui/client/htmlexport_data/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  15. myrtui/client/htmlexport_data/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  16. myrtui/client/htmlexport_data/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  17. myrtui/client/htmlexport_data/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  18. myrtui/client/htmlexport_data/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  19. myrtui/client/htmlexport_data/fonts/KaTeX_Main-Bold.woff2 +0 -0
  20. myrtui/client/htmlexport_data/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  21. myrtui/client/htmlexport_data/fonts/KaTeX_Main-Italic.woff2 +0 -0
  22. myrtui/client/htmlexport_data/fonts/KaTeX_Main-Regular.woff2 +0 -0
  23. myrtui/client/htmlexport_data/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  24. myrtui/client/htmlexport_data/fonts/KaTeX_Math-Italic.woff2 +0 -0
  25. myrtui/client/htmlexport_data/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  26. myrtui/client/htmlexport_data/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  27. myrtui/client/htmlexport_data/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  28. myrtui/client/htmlexport_data/fonts/KaTeX_Script-Regular.woff2 +0 -0
  29. myrtui/client/htmlexport_data/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  30. myrtui/client/htmlexport_data/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  31. myrtui/client/htmlexport_data/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  32. myrtui/client/htmlexport_data/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  33. myrtui/client/htmlexport_data/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  34. myrtui/client/htmlexport_data/katex.min.css +1 -0
  35. myrtui/client/htmlexport_data/katex.min.js +1 -0
  36. myrtui/client/imageprep.py +233 -0
  37. myrtui/client/imagepreview.py +414 -0
  38. myrtui/client/katex.py +222 -0
  39. myrtui/client/mathimg.py +1053 -0
  40. myrtui/client/mathrender.py +554 -0
  41. myrtui/client/mathtext.py +249 -0
  42. myrtui/client/mdpreview.py +709 -0
  43. myrtui/client/pageview.py +291 -0
  44. myrtui/client/pane_widget.py +415 -0
  45. myrtui/client/separators.py +119 -0
  46. myrtui/client/shortcuts.py +165 -0
  47. myrtui/client/sidebar.py +183 -0
  48. myrtui/client/topbar.py +157 -0
  49. myrtui/fileview.py +175 -0
  50. myrtui/gitlog.py +184 -0
  51. myrtui/gitstat.py +193 -0
  52. myrtui/i18n.py +148 -0
  53. myrtui/instances.py +418 -0
  54. myrtui/layout_state.py +138 -0
  55. myrtui/locales/__init__.py +4 -0
  56. myrtui/locales/en.py +231 -0
  57. myrtui/locales/zh_CN.py +228 -0
  58. myrtui/mouse.py +199 -0
  59. myrtui/pane_commands.py +264 -0
  60. myrtui/tmux_backend.py +465 -0
  61. myrtui-0.5.0.dist-info/METADATA +187 -0
  62. myrtui-0.5.0.dist-info/RECORD +65 -0
  63. myrtui-0.5.0.dist-info/WHEEL +4 -0
  64. myrtui-0.5.0.dist-info/entry_points.txt +2 -0
  65. myrtui-0.5.0.dist-info/licenses/LICENSE +21 -0
myrtui/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """myrtui:面向单个项目的轻量终端复用器(Textual 前端 + tmux 后端)。"""
2
+
3
+ __version__ = "0.5.0"
myrtui/cli.py ADDED
@@ -0,0 +1,522 @@
1
+ """myrtui 入口(DESIGN.md §4 cli.py / §6 / §9 / §12 P2 启动参数)。
2
+
3
+ 流程:
4
+ 1. 解析启动参数(P2:``--root`` / ``--shell`` / ``--scrollback``)并校验;
5
+ 2. hash 派生:``os.path.realpath(...)`` 的 sha256 前 16 位 hex(M19:
6
+ 必须物理路径,禁用 $PWD / 未归一化路径,否则符号链接路径会分裂出会话;
7
+ ``--root`` 同样先 realpath);
8
+ 3. 启动期 ``tmux -V`` 校验(M4/M16:正则解析,失败放行,< 3.1 拒绝);
9
+ 4. flock 抢占(§6):P1 为「旁挂 ``<hash>.takeover`` 接管标记 + 保鲜校验交接」,
10
+ 抢不到锁时写标记并轮询等锁(带超时),全程无 pid kill;锁文件永不 unlink,
11
+ inode 恒定(M14);
12
+ 5. 无会话则按 M13 序列创建(先 set -g 后 new-session,单次调用,含 P2 的
13
+ shell / history-limit);会话已存在则把显式参数下发给**之后新建**的 pane;
14
+ 6. 进入 Textual UI(持锁期间运行 §6 哨戒:读接管标记、两级校验通过才让位)。
15
+
16
+ 实例管理子命令(DISTRIBUTION-INSTANCES-PLAN §3):``argv[1] ∈ {ls, list,
17
+ attach, stop}`` 时预分发到 ``myrtui.instances``,本模块只负责裸启动路径。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import fcntl
23
+ import hashlib
24
+ import logging
25
+ import os
26
+ import shutil
27
+ import sys
28
+ import time
29
+
30
+ import platformdirs
31
+
32
+ from myrtui.i18n import LANGUAGES, set_lang, t
33
+
34
+ from myrtui.tmux_backend import (
35
+ DEFAULT_HISTORY_LIMIT,
36
+ TmuxError,
37
+ TmuxNotFoundError,
38
+ TmuxBackend,
39
+ check_tmux,
40
+ )
41
+
42
+ log = logging.getLogger("myrtui")
43
+
44
+ APP_NAME = "myrtui"
45
+
46
+ # §11 / §12 P2:--scrollback 取值范围(0 无意义,过大值等于让用户手滑吃内存)
47
+ MAX_SCROLLBACK = 1_000_000
48
+
49
+ # ── §6 P1 接管交接参数 ──────────────────────────────────────────────
50
+ TAKEOVER_POLL = 0.1 # LOCK_NB 轮询等锁间隔
51
+ TAKEOVER_REFRESH = 1.0 # 标记刷新周期(保持新鲜,远小于保鲜期)
52
+ TAKEOVER_TIMEOUT = 5.0 # 等锁总超时(发起方正常 1~2s 内完成交接)
53
+ TAKEOVER_MAX_AGE = 10.0 # 标记保鲜期(M15:过期按垃圾处理)
54
+ SENTRY_INTERVAL = 0.5 # 持锁者哨戒读周期(§6:每 500ms 读接管标记)
55
+
56
+
57
+ def derive_identity(cwd: str | None = None) -> tuple[str, str]:
58
+ """返回 (project_root, hash)。root 取 realpath(M19),禁用 $PWD。
59
+
60
+ P2 的 ``--root`` 走同一条路径:同样先 realpath 再派生——否则
61
+ 「符号链接路径 → 两套锁/两套 socket/两个会话」的分裂会在 --root 上重演。
62
+ """
63
+ root = os.path.realpath(cwd or os.getcwd())
64
+ digest = hashlib.sha256(root.encode("utf-8")).hexdigest()[:16]
65
+ return root, digest
66
+
67
+
68
+ def _data_dir() -> str:
69
+ data_dir = platformdirs.user_data_dir(APP_NAME, appauthor=False)
70
+ os.makedirs(data_dir, exist_ok=True) # M20:首次必须建
71
+ return data_dir
72
+
73
+
74
+ def lock_path_for(digest: str) -> str:
75
+ """锁文件位置:<data>/myrtui/<hash>.lock(§6 M17:user_data_dir)。
76
+
77
+ 锁文件本身永不 unlink(inode 恒定是 flock 互斥的前提,M14/M17)。
78
+ """
79
+ return os.path.join(_data_dir(), f"{digest}.lock")
80
+
81
+
82
+ def takeover_path_for(digest: str) -> str:
83
+ """旁挂接管标记:<data>/myrtui/<hash>.takeover(§6 M21 默认通道)。
84
+
85
+ 与锁文件不同,它不承载 flock 语义 → 允许 temp+rename 原子写、
86
+ 允许 unlink(锁到手后清理)。
87
+ """
88
+ return os.path.join(_data_dir(), f"{digest}.takeover")
89
+
90
+
91
+ def stop_path_for(digest: str) -> str:
92
+ """旁挂停止标记:<data>/myrtui/<hash>.stop(实例管理命令 ``myrtui stop``)。
93
+
94
+ 与 takeover 分开两个文件(DISTRIBUTION-INSTANCES-PLAN §3.4 / 评审 R6):
95
+ takeover 标记被等锁的 attach 者每 1s 刷新、语义是「我要前台」;
96
+ stop 的语义是「请整个退场」。混用同一文件会让让位与停止互相干扰,
97
+ 也会让等待中的 attach 者误触发停止。写法同 takeover(原子写、可 unlink)。
98
+ """
99
+ return os.path.join(_data_dir(), f"{digest}.stop")
100
+
101
+
102
+ def layout_path_for(digest: str) -> str:
103
+ """布局比例文件:<data>/myrtui/<hash>.layout.json(v0.11)。
104
+
105
+ 与锁 / 接管标记同目录同命名约定(按项目 hash 分文件 → 同一项目的
106
+ 下次 attach 能恢复上次的分割位置,不同项目互不干扰)。
107
+ """
108
+ return os.path.join(_data_dir(), f"{digest}.layout.json")
109
+
110
+
111
+ # ── 接管标记:写 / 读 / 校验(§6 P1,M15 两级校验 + M21 异常语义) ──
112
+
113
+
114
+ def write_takeover_marker(
115
+ path: str,
116
+ pid: int | None = None,
117
+ ts: float | None = None,
118
+ *,
119
+ prefix: str = "takeover",
120
+ ) -> None:
121
+ """原子写 ``<prefix>:<pid>:<时间戳>``(temp+rename;旁挂文件专属写法)。
122
+
123
+ ``prefix`` 区分两种旁挂标记(评审 R6):``takeover`` = 请让位给我,
124
+ ``stop`` = 请整个退场(``myrtui stop``)。默认值保持接管路径零改动。
125
+ """
126
+ pid = os.getpid() if pid is None else pid
127
+ ts = time.time() if ts is None else ts
128
+ tmp = f"{path}.{os.getpid()}.tmp"
129
+ with open(tmp, "w") as f:
130
+ f.write(f"{prefix}:{pid}:{ts}\n")
131
+ f.flush()
132
+ os.fsync(f.fileno())
133
+ os.replace(tmp, path) # 仅旁挂文件允许 replace(不承载 flock 语义)
134
+
135
+
136
+ def clear_takeover(path: str) -> None:
137
+ try:
138
+ os.unlink(path)
139
+ except FileNotFoundError:
140
+ pass
141
+
142
+
143
+ def read_takeover_markers(path: str, *, prefix: str = "takeover") -> list[tuple[int, float]]:
144
+ """读取旁挂标记,返回 [(pid, ts), ...];``prefix`` 过滤标记类型(评审 R6)。
145
+
146
+ M14 读取容忍:原地并发/半截内容/格式异常一律视为「无该类请求」,
147
+ 不得据此触发任何动作。
148
+ """
149
+ try:
150
+ with open(path) as f:
151
+ content = f.read()
152
+ except OSError:
153
+ return []
154
+ markers: list[tuple[int, float]] = []
155
+ for line in content.splitlines():
156
+ parts = line.split(":")
157
+ if len(parts) != 3 or parts[0] != prefix:
158
+ continue
159
+ try:
160
+ markers.append((int(parts[1]), float(parts[2])))
161
+ except ValueError:
162
+ continue
163
+ return markers
164
+
165
+
166
+ def is_marker_valid(pid: int, ts: float, now: float | None = None) -> bool:
167
+ """M15 两级校验:标记新鲜 + 发起方存活。全过才允许让位。
168
+
169
+ M21 异常语义(写死):``os.kill(pid, 0)`` 只有 ``ProcessLookupError``
170
+ 表示「不存在」;``PermissionError`` 等其余异常一律视为「不是我们的前端」
171
+ → 不让位(实测 pid 1 即 PermissionError)。误判方向安全(至多不让位)。
172
+ """
173
+ now = time.time() if now is None else now
174
+ if now - ts >= TAKEOVER_MAX_AGE:
175
+ return False
176
+ try:
177
+ os.kill(pid, 0)
178
+ except ProcessLookupError:
179
+ return False # 发起方已死 → 标记是垃圾
180
+ except Exception:
181
+ return False # PermissionError 等 → 不是我们控制的前端 → 不让位
182
+ return True
183
+
184
+
185
+ # ── 锁获取 ──────────────────────────────────────────────────────────
186
+
187
+
188
+ def acquire_lock(path: str, *, root: str | None = None, write_record: bool = True) -> int:
189
+ """P0 语义:抢不到锁即拒绝(保留给测试与无接管路径)。
190
+
191
+ fd 用 O_RDWR 一次打开(M22:flock 与记录读写共用)。
192
+ ``root`` 非空时把项目根写进持有者记录第二行(实例管理 ls/attach 反查用);
193
+ ``write_record=False`` 供 ``myrtui stop`` 使用——它拿锁只为 kill 会话,
194
+ 不该把锁文件记录覆写成 stop 进程自己的 pid/root(评审 R3)。
195
+ """
196
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
197
+ try:
198
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
199
+ except BlockingIOError:
200
+ os.close(fd)
201
+ raise
202
+ if write_record:
203
+ write_holder_record(fd, root=root)
204
+ return fd
205
+
206
+
207
+ def write_holder_record(fd: int, root: str | None = None) -> None:
208
+ """原地覆写持有者记录(M14:seek+truncate+write,严禁 rename/replace)。
209
+
210
+ 格式(实例管理,计划 §3.1):第一行 ``pid``;``root`` 非空时第二行
211
+ 项目根路径——``myrtui ls/attach/stop`` 靠它把 hash 反查回项目目录。
212
+ ``root=None`` 保持旧单行格式(无项目上下文的调用方,兼容既有测试)。
213
+ """
214
+ os.lseek(fd, 0, os.SEEK_SET)
215
+ os.ftruncate(fd, 0)
216
+ data = f"{os.getpid()}\n" if root is None else f"{os.getpid()}\n{root}\n"
217
+ os.write(fd, data.encode())
218
+
219
+
220
+ def read_holder_record(path: str) -> tuple[int | None, str | None]:
221
+ """读持有者记录,返回 ``(pid, root)``;缺失/半截/格式异常容忍为 ``(None, None)``。
222
+
223
+ 旧格式(单行 pid)→ ``(pid, None)``;根目录行缺失同样 ``root=None``。
224
+ """
225
+ try:
226
+ with open(path) as f:
227
+ lines = f.read().splitlines()
228
+ except OSError:
229
+ return None, None
230
+ if not lines:
231
+ return None, None
232
+ try:
233
+ pid: int | None = int(lines[0])
234
+ except ValueError:
235
+ pid = None
236
+ root = lines[1] if len(lines) > 1 and lines[1] else None
237
+ return pid, root
238
+
239
+
240
+ def probe_lock_held(path: str) -> bool:
241
+ """flock 试探(``myrtui ls`` 专用,评审 M6/R3):只判不写。
242
+
243
+ ``open + LOCK_EX|LOCK_NB + 立即 unlock + close``——不走 ``acquire_lock*``
244
+ (那会覆写 holder record 成试探者自己的 pid,脏记录)。持锁仅微秒级,
245
+ 期间真前端抢锁失败会走接管轮询,放锁后下一轮即成功(计划 §5 R3)。
246
+ """
247
+ try:
248
+ fd = os.open(path, os.O_RDWR)
249
+ except FileNotFoundError:
250
+ return False # 锁文件不存在 = 无实例(glob 扫描时理论上不会到这)
251
+ try:
252
+ try:
253
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
254
+ except BlockingIOError:
255
+ return True # 有人持锁 = 前台运行中
256
+ fcntl.flock(fd, fcntl.LOCK_UN)
257
+ return False
258
+ finally:
259
+ os.close(fd)
260
+
261
+
262
+ def _drop_own_marker(path: str) -> None:
263
+ """超时/异常离场时清掉自己的标记(别人的标记不碰)。
264
+
265
+ 不清的话:标记里的 pid 仍是本进程且新鲜,持锁者哨戒会对一个已经
266
+ 放弃等待的前端让位,白丢一次前台。
267
+ """
268
+ if any(pid == os.getpid() for pid, _ in read_takeover_markers(path)):
269
+ clear_takeover(path)
270
+
271
+
272
+ def acquire_lock_with_takeover(
273
+ lock_path: str,
274
+ takeover_path: str,
275
+ *,
276
+ timeout: float | None = None,
277
+ poll: float | None = None,
278
+ refresh: float | None = None,
279
+ root: str | None = None,
280
+ ) -> int:
281
+ """P1 抢占:抢不到锁 → 写接管标记 + 轮询等锁(带超时),无任何 pid kill。
282
+
283
+ 等待期间周期刷新标记保持新鲜(§6 步骤 1);锁到手后原地覆写持有者
284
+ 记录并清掉旁挂标记(§6 步骤 3)。超时抛 ``TimeoutError``(§9)。
285
+ timeout/poll/refresh 默认取模块常量,显式参数仅供测试缩短时长。
286
+ ``root`` 写入持有者记录第二行(同 ``acquire_lock``)。
287
+ """
288
+ timeout = TAKEOVER_TIMEOUT if timeout is None else timeout
289
+ poll = TAKEOVER_POLL if poll is None else poll
290
+ refresh = TAKEOVER_REFRESH if refresh is None else refresh
291
+ fd = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
292
+ deadline = time.monotonic() + timeout
293
+ next_refresh = 0.0
294
+ try:
295
+ while True:
296
+ try:
297
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
298
+ break
299
+ except BlockingIOError:
300
+ pass
301
+ now = time.monotonic()
302
+ if now >= deadline:
303
+ raise TimeoutError(t("cli.lock_timeout", timeout=timeout))
304
+ if now >= next_refresh:
305
+ write_takeover_marker(takeover_path)
306
+ next_refresh = now + refresh
307
+ time.sleep(poll)
308
+ except BaseException:
309
+ # 含 TimeoutError / KeyboardInterrupt:不把已放弃的标记留给哨戒者
310
+ os.close(fd)
311
+ _drop_own_marker(takeover_path)
312
+ raise
313
+ write_holder_record(fd, root=root)
314
+ clear_takeover(takeover_path)
315
+ return fd
316
+
317
+
318
+ # ── 启动参数(§12 P2) ──────────────────────────────────────────────
319
+
320
+ # 实例管理子命令(计划 §3.5):main() 按 argv[1] 字面量预分发到
321
+ # myrtui.instances;取值空间原本就无位置参数(`myrtui ls` 现为用法错误),
322
+ # 预分发不缩小既有合法输入集。
323
+ SUBCOMMANDS = frozenset({"ls", "list", "attach", "stop"})
324
+
325
+
326
+ def _version_string() -> str:
327
+ """``--version`` 用:包未以分发形式安装(裸跑源码)→ unknown。"""
328
+ from importlib.metadata import PackageNotFoundError, version
329
+
330
+ try:
331
+ return version("myrtui")
332
+ except PackageNotFoundError:
333
+ return "unknown"
334
+
335
+
336
+ def build_parser() -> argparse.ArgumentParser:
337
+ """``myrtui [--root PATH] [--shell PATH] [--scrollback N] [--version]``。
338
+
339
+ 取值畸形(非整数的 --scrollback 等)交给 argparse → 退出码 2(用法错误);
340
+ 取值合法但环境不满足(root 不是目录、shell 不可执行、越界)由 ``main``
341
+ 打印原因后返回 1(运行错误,与既有错误路径口径一致)。
342
+
343
+ 子命令 ``ls`` / ``list`` / ``attach`` / ``stop`` 不进本解析器——由
344
+ ``main`` 按 ``argv[1]`` 字面量预分发(实例管理)。
345
+ """
346
+ p = argparse.ArgumentParser(
347
+ prog="myrtui",
348
+ description=t("cli.description"),
349
+ epilog=t("cli.epilog"),
350
+ )
351
+ p.add_argument(
352
+ "--version",
353
+ action="version",
354
+ version=f"myrtui {_version_string()}",
355
+ help=t("cli.version_help"),
356
+ )
357
+ p.add_argument(
358
+ "--root",
359
+ metavar="PATH",
360
+ help=t("cli.root_help"),
361
+ )
362
+ p.add_argument(
363
+ "--shell",
364
+ metavar="PATH",
365
+ help=t("cli.shell_help"),
366
+ )
367
+ p.add_argument(
368
+ "--scrollback",
369
+ type=int,
370
+ metavar="N",
371
+ help=t("cli.scrollback_help", default=DEFAULT_HISTORY_LIMIT, max=MAX_SCROLLBACK),
372
+ )
373
+ p.add_argument(
374
+ "--lang",
375
+ choices=LANGUAGES,
376
+ help=t("cli.lang_help", langs=" / ".join(LANGUAGES)),
377
+ )
378
+ return p
379
+
380
+
381
+ def _take_lang(argv: list[str]) -> list[str]:
382
+ """把 ``--lang`` 从 argv 里摘出来并生效。
383
+
384
+ 子命令(``ls`` / ``attach`` / ``stop``)由各自的小解析器处理,它们不认识
385
+ ``--lang``;而语言必须在打印任何提示之前定下来,所以在预分发前统一摘掉。
386
+ """
387
+ rest: list[str] = []
388
+ i = 0
389
+ while i < len(argv):
390
+ arg = argv[i]
391
+ if arg == "--lang" and i + 1 < len(argv):
392
+ set_lang(argv[i + 1])
393
+ i += 2
394
+ continue
395
+ if arg.startswith("--lang="):
396
+ set_lang(arg.split("=", 1)[1])
397
+ i += 1
398
+ continue
399
+ rest.append(arg)
400
+ i += 1
401
+ return rest
402
+
403
+
404
+ def main(argv: list[str] | None = None) -> int:
405
+ logging.basicConfig(level=logging.WARNING)
406
+ argv = sys.argv[1:] if argv is None else list(argv)
407
+
408
+ # 0. 子命令预分发(计划 §3.5):只认 argv[1] 恰为这些字面量。
409
+ # `myrtui --root ls` 的 argv[1] 是 `--root` → 不会误分发。
410
+ if argv and argv[0] in SUBCOMMANDS:
411
+ from myrtui import instances
412
+
413
+ try:
414
+ argv = _take_lang(argv)
415
+ except ValueError as exc: # --lang 取值不在 LANGUAGES
416
+ print(f"myrtui: {exc}", file=sys.stderr)
417
+ return 2
418
+ return instances.run("ls" if argv[0] == "list" else argv[0], argv[1:])
419
+
420
+ args = build_parser().parse_args(argv)
421
+ set_lang(args.lang)
422
+
423
+ # 1. 启动参数校验(全部在抢锁/连 tmux 之前,fail fast)
424
+ if args.root is not None and not os.path.isdir(args.root):
425
+ print(t("cli.err_root", path=args.root), file=sys.stderr)
426
+ return 1
427
+ shell: str | None = None
428
+ if args.shell is not None:
429
+ shell = os.path.realpath(args.shell)
430
+ if not os.path.isfile(shell) or not os.access(shell, os.X_OK):
431
+ print(t("cli.err_shell", path=args.shell), file=sys.stderr)
432
+ return 1
433
+ if args.scrollback is not None and not (
434
+ 1 <= args.scrollback <= MAX_SCROLLBACK
435
+ ):
436
+ print(
437
+ t("cli.err_scrollback", max=MAX_SCROLLBACK, value=args.scrollback),
438
+ file=sys.stderr,
439
+ )
440
+ return 1
441
+
442
+ # 2. 身份派生:--root 与 cwd 同一路径(M19:一律 realpath)
443
+ root, digest = derive_identity(args.root)
444
+ socket_name = f"{APP_NAME}-{digest}"
445
+ session_name = socket_name
446
+
447
+ try:
448
+ check_tmux()
449
+ except (TmuxNotFoundError, TmuxError) as exc:
450
+ print(f"myrtui: {exc}", file=sys.stderr)
451
+ return 1
452
+
453
+ lock_path = lock_path_for(digest)
454
+ takeover_path = takeover_path_for(digest)
455
+ try:
456
+ lock_fd = acquire_lock_with_takeover(lock_path, takeover_path, root=root)
457
+ except TimeoutError:
458
+ # §9:P1 接管轮询超时后提示手动处理并退出
459
+ print(
460
+ t("cli.err_busy", root=root, timeout=TAKEOVER_TIMEOUT),
461
+ file=sys.stderr,
462
+ )
463
+ return 1
464
+
465
+ backend = TmuxBackend(socket_name, session_name)
466
+ try:
467
+ if not backend.has_session():
468
+ cols, rows = shutil.get_terminal_size()
469
+ backend.create_session(
470
+ root,
471
+ cols,
472
+ rows,
473
+ shell=shell,
474
+ history_limit=args.scrollback,
475
+ )
476
+ else:
477
+ # v0.31:老会话没有 set-clipboard on,启动时补一次(分页器复制兜底)
478
+ backend.enable_clipboard_capture()
479
+ if shell is not None or args.scrollback is not None:
480
+ # 会话已存在:tmux 在创建时快照这两个选项 → 只对之后新建的
481
+ # pane/window 生效(respawn 重跑的也是建窗时记录的命令)
482
+ backend.apply_session_options(
483
+ shell=shell, history_limit=args.scrollback
484
+ )
485
+ print(
486
+ "myrtui: --shell / --scrollback 对已存在的会话只影响"
487
+ "之后新建的 pane(tmux 在创建时快照选项)。",
488
+ file=sys.stderr,
489
+ )
490
+ except TmuxError as exc:
491
+ print(f"myrtui: {exc}", file=sys.stderr)
492
+ os.close(lock_fd)
493
+ return 1
494
+
495
+ # v0.37:位图能力必须在 Textual 接管 stdin **之前**探测(textual-image
496
+ # 要读 DA1 回复);缺依赖 / 非 tty / 探测异常都只是「无位图」。
497
+ from myrtui.client.graphics import describe, detect_silent
498
+
499
+ graphics = detect_silent()
500
+ log.debug("terminal graphics: %s", describe(graphics))
501
+
502
+ from myrtui.client.app import TerminalApp
503
+
504
+ app = TerminalApp(
505
+ backend,
506
+ root,
507
+ takeover_path=takeover_path,
508
+ stop_path=stop_path_for(digest),
509
+ layout_path=layout_path_for(digest),
510
+ graphics=graphics,
511
+ )
512
+ try:
513
+ app.run()
514
+ finally:
515
+ # 前端退出(含接管让位)= detach;tmux 会话与 shell 继续存活(§3.1)。
516
+ # 锁文件不删除(永不 unlink),fd 随进程退出由内核释放。
517
+ os.close(lock_fd)
518
+ return 0
519
+
520
+
521
+ if __name__ == "__main__":
522
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """myrtui Textual 前端包。"""