py-screen 20261110.1__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.
Files changed (142) hide show
  1. py_screen-20261110.1/LICENSE +21 -0
  2. py_screen-20261110.1/MANIFEST.in +3 -0
  3. py_screen-20261110.1/PKG-INFO +586 -0
  4. py_screen-20261110.1/README.md +558 -0
  5. py_screen-20261110.1/deps/README.md +73 -0
  6. py_screen-20261110.1/deps/libvterm/.bzrignore +13 -0
  7. py_screen-20261110.1/deps/libvterm/CODE-MAP +81 -0
  8. py_screen-20261110.1/deps/libvterm/CONTRIBUTING +22 -0
  9. py_screen-20261110.1/deps/libvterm/LICENSE +23 -0
  10. py_screen-20261110.1/deps/libvterm/Makefile +142 -0
  11. py_screen-20261110.1/deps/libvterm/bin/unterm.c +280 -0
  12. py_screen-20261110.1/deps/libvterm/bin/vterm-ctrl.c +354 -0
  13. py_screen-20261110.1/deps/libvterm/bin/vterm-dump.c +245 -0
  14. py_screen-20261110.1/deps/libvterm/doc/URLs +14 -0
  15. py_screen-20261110.1/deps/libvterm/doc/seqs.txt +283 -0
  16. py_screen-20261110.1/deps/libvterm/find-wide-chars.pl +30 -0
  17. py_screen-20261110.1/deps/libvterm/include/vterm.h +637 -0
  18. py_screen-20261110.1/deps/libvterm/include/vterm_keycodes.h +61 -0
  19. py_screen-20261110.1/deps/libvterm/src/encoding/DECdrawing.inc +36 -0
  20. py_screen-20261110.1/deps/libvterm/src/encoding/DECdrawing.tbl +31 -0
  21. py_screen-20261110.1/deps/libvterm/src/encoding/uk.inc +6 -0
  22. py_screen-20261110.1/deps/libvterm/src/encoding/uk.tbl +1 -0
  23. py_screen-20261110.1/deps/libvterm/src/encoding.c +230 -0
  24. py_screen-20261110.1/deps/libvterm/src/fullwidth.inc +111 -0
  25. py_screen-20261110.1/deps/libvterm/src/keyboard.c +226 -0
  26. py_screen-20261110.1/deps/libvterm/src/mouse.c +99 -0
  27. py_screen-20261110.1/deps/libvterm/src/parser.c +402 -0
  28. py_screen-20261110.1/deps/libvterm/src/pen.c +607 -0
  29. py_screen-20261110.1/deps/libvterm/src/rect.h +56 -0
  30. py_screen-20261110.1/deps/libvterm/src/screen.c +1197 -0
  31. py_screen-20261110.1/deps/libvterm/src/state.c +2377 -0
  32. py_screen-20261110.1/deps/libvterm/src/unicode.c +313 -0
  33. py_screen-20261110.1/deps/libvterm/src/utf8.h +39 -0
  34. py_screen-20261110.1/deps/libvterm/src/vterm.c +430 -0
  35. py_screen-20261110.1/deps/libvterm/src/vterm_internal.h +297 -0
  36. py_screen-20261110.1/deps/libvterm/t/02parser.test +266 -0
  37. py_screen-20261110.1/deps/libvterm/t/03encoding_utf8.test +122 -0
  38. py_screen-20261110.1/deps/libvterm/t/10state_putglyph.test +74 -0
  39. py_screen-20261110.1/deps/libvterm/t/11state_movecursor.test +224 -0
  40. py_screen-20261110.1/deps/libvterm/t/12state_scroll.test +156 -0
  41. py_screen-20261110.1/deps/libvterm/t/13state_edit.test +304 -0
  42. py_screen-20261110.1/deps/libvterm/t/14state_encoding.test +105 -0
  43. py_screen-20261110.1/deps/libvterm/t/15state_mode.test +86 -0
  44. py_screen-20261110.1/deps/libvterm/t/16state_resize.test +48 -0
  45. py_screen-20261110.1/deps/libvterm/t/17state_mouse.test +191 -0
  46. py_screen-20261110.1/deps/libvterm/t/18state_termprops.test +42 -0
  47. py_screen-20261110.1/deps/libvterm/t/20state_wrapping.test +69 -0
  48. py_screen-20261110.1/deps/libvterm/t/21state_tabstops.test +60 -0
  49. py_screen-20261110.1/deps/libvterm/t/22state_save.test +64 -0
  50. py_screen-20261110.1/deps/libvterm/t/25state_input.test +157 -0
  51. py_screen-20261110.1/deps/libvterm/t/26state_query.test +63 -0
  52. py_screen-20261110.1/deps/libvterm/t/27state_reset.test +32 -0
  53. py_screen-20261110.1/deps/libvterm/t/28state_dbl_wh.test +61 -0
  54. py_screen-20261110.1/deps/libvterm/t/29state_fallback.test +31 -0
  55. py_screen-20261110.1/deps/libvterm/t/30state_pen.test +133 -0
  56. py_screen-20261110.1/deps/libvterm/t/31state_rep.test +128 -0
  57. py_screen-20261110.1/deps/libvterm/t/32state_flow.test +28 -0
  58. py_screen-20261110.1/deps/libvterm/t/40state_selection.test +89 -0
  59. py_screen-20261110.1/deps/libvterm/t/60screen_ascii.test +69 -0
  60. py_screen-20261110.1/deps/libvterm/t/61screen_unicode.test +47 -0
  61. py_screen-20261110.1/deps/libvterm/t/62screen_damage.test +155 -0
  62. py_screen-20261110.1/deps/libvterm/t/63screen_resize.test +117 -0
  63. py_screen-20261110.1/deps/libvterm/t/64screen_pen.test +71 -0
  64. py_screen-20261110.1/deps/libvterm/t/65screen_protect.test +16 -0
  65. py_screen-20261110.1/deps/libvterm/t/66screen_extent.test +11 -0
  66. py_screen-20261110.1/deps/libvterm/t/67screen_dbl_wh.test +38 -0
  67. py_screen-20261110.1/deps/libvterm/t/68screen_termprops.test +17 -0
  68. py_screen-20261110.1/deps/libvterm/t/69screen_reflow.test +88 -0
  69. py_screen-20261110.1/deps/libvterm/t/90vttest_01-movement-1.test +87 -0
  70. py_screen-20261110.1/deps/libvterm/t/90vttest_01-movement-2.test +40 -0
  71. py_screen-20261110.1/deps/libvterm/t/90vttest_01-movement-3.test +21 -0
  72. py_screen-20261110.1/deps/libvterm/t/90vttest_01-movement-4.test +36 -0
  73. py_screen-20261110.1/deps/libvterm/t/90vttest_02-screen-1.test +18 -0
  74. py_screen-20261110.1/deps/libvterm/t/90vttest_02-screen-2.test +29 -0
  75. py_screen-20261110.1/deps/libvterm/t/90vttest_02-screen-3.test +16 -0
  76. py_screen-20261110.1/deps/libvterm/t/90vttest_02-screen-4.test +17 -0
  77. py_screen-20261110.1/deps/libvterm/t/92lp1640917.test +13 -0
  78. py_screen-20261110.1/deps/libvterm/t/harness.c +1235 -0
  79. py_screen-20261110.1/deps/libvterm/t/run-test.pl +234 -0
  80. py_screen-20261110.1/deps/libvterm/tbl2inc_c.pl +30 -0
  81. py_screen-20261110.1/deps/libvterm/vterm.pc.in +8 -0
  82. py_screen-20261110.1/pyproject.toml +67 -0
  83. py_screen-20261110.1/setup.cfg +4 -0
  84. py_screen-20261110.1/setup.py +24 -0
  85. py_screen-20261110.1/src/py_screen/__init__.py +195 -0
  86. py_screen-20261110.1/src/py_screen/__main__.py +3 -0
  87. py_screen-20261110.1/src/py_screen/_vterm.c +1180 -0
  88. py_screen-20261110.1/src/py_screen/agents/__init__.py +16 -0
  89. py_screen-20261110.1/src/py_screen/agents/claude.py +162 -0
  90. py_screen-20261110.1/src/py_screen/agents/gitinfo.py +98 -0
  91. py_screen-20261110.1/src/py_screen/agents/instructions.py +74 -0
  92. py_screen-20261110.1/src/py_screen/agents/listing.py +253 -0
  93. py_screen-20261110.1/src/py_screen/agents/procs.py +186 -0
  94. py_screen-20261110.1/src/py_screen/agents/reopen.py +115 -0
  95. py_screen-20261110.1/src/py_screen/agents/sessions.py +584 -0
  96. py_screen-20261110.1/src/py_screen/agents/setup.py +130 -0
  97. py_screen-20261110.1/src/py_screen/agents/skills/pys-drive/SKILL.md +71 -0
  98. py_screen-20261110.1/src/py_screen/agents/skills/pys-restore/SKILL.md +67 -0
  99. py_screen-20261110.1/src/py_screen/agents/widget.py +92 -0
  100. py_screen-20261110.1/src/py_screen/brain.py +1737 -0
  101. py_screen-20261110.1/src/py_screen/client.py +83 -0
  102. py_screen-20261110.1/src/py_screen/config.py +266 -0
  103. py_screen-20261110.1/src/py_screen/copymode.py +356 -0
  104. py_screen-20261110.1/src/py_screen/doctor.py +158 -0
  105. py_screen-20261110.1/src/py_screen/keeper.py +242 -0
  106. py_screen-20261110.1/src/py_screen/layout.py +219 -0
  107. py_screen-20261110.1/src/py_screen/mouse.py +92 -0
  108. py_screen-20261110.1/src/py_screen/platform/__init__.py +36 -0
  109. py_screen-20261110.1/src/py_screen/platform/posix.py +265 -0
  110. py_screen-20261110.1/src/py_screen/platform/windows.py +406 -0
  111. py_screen-20261110.1/src/py_screen/proto.py +78 -0
  112. py_screen-20261110.1/src/py_screen/sample_config.py +297 -0
  113. py_screen-20261110.1/src/py_screen/screen.py +358 -0
  114. py_screen-20261110.1/src/py_screen/term.py +207 -0
  115. py_screen-20261110.1/src/py_screen/vterm.py +16 -0
  116. py_screen-20261110.1/src/py_screen.egg-info/PKG-INFO +586 -0
  117. py_screen-20261110.1/src/py_screen.egg-info/SOURCES.txt +140 -0
  118. py_screen-20261110.1/src/py_screen.egg-info/dependency_links.txt +1 -0
  119. py_screen-20261110.1/src/py_screen.egg-info/entry_points.txt +4 -0
  120. py_screen-20261110.1/src/py_screen.egg-info/requires.txt +10 -0
  121. py_screen-20261110.1/src/py_screen.egg-info/top_level.txt +1 -0
  122. py_screen-20261110.1/tests/test_agents.py +453 -0
  123. py_screen-20261110.1/tests/test_agents_smoke.py +213 -0
  124. py_screen-20261110.1/tests/test_bottom.py +119 -0
  125. py_screen-20261110.1/tests/test_copymode.py +305 -0
  126. py_screen-20261110.1/tests/test_doctor.py +114 -0
  127. py_screen-20261110.1/tests/test_exit_guard.py +38 -0
  128. py_screen-20261110.1/tests/test_flood.py +106 -0
  129. py_screen-20261110.1/tests/test_home_path.py +35 -0
  130. py_screen-20261110.1/tests/test_lifecycle.py +135 -0
  131. py_screen-20261110.1/tests/test_m0.py +110 -0
  132. py_screen-20261110.1/tests/test_m1.py +210 -0
  133. py_screen-20261110.1/tests/test_mouse.py +179 -0
  134. py_screen-20261110.1/tests/test_nested.py +72 -0
  135. py_screen-20261110.1/tests/test_parser.py +120 -0
  136. py_screen-20261110.1/tests/test_redraw.py +97 -0
  137. py_screen-20261110.1/tests/test_rpc_smoke.py +119 -0
  138. py_screen-20261110.1/tests/test_sample_config.py +170 -0
  139. py_screen-20261110.1/tests/test_scrollback.py +140 -0
  140. py_screen-20261110.1/tests/test_setup.py +130 -0
  141. py_screen-20261110.1/tests/test_title.py +32 -0
  142. py_screen-20261110.1/tests/test_vterm.py +129 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cong Zhang
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.
@@ -0,0 +1,3 @@
1
+ # The sdist carries libvterm's sources and licence, so a wheel builds from it alone.
2
+ graft deps/libvterm
3
+ include deps/README.md
@@ -0,0 +1,586 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-screen
3
+ Version: 20261110.1
4
+ Summary: py-screen: a terminal workspace (multiplexer) of my own, with the pys command
5
+ Author-email: Cong Zhang <13283869+congzhangzh@users.noreply.github.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/congzhangzh/py-screen
8
+ Project-URL: Source, https://github.com/congzhangzh/py-screen
9
+ Project-URL: Releases, https://github.com/congzhangzh/py-screen/releases
10
+ Project-URL: Issues, https://github.com/congzhangzh/py-screen/issues
11
+ Classifier: Environment :: Console
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: MacOS
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Programming Language :: C
17
+ Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
18
+ Requires-Python: >=3.14
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ License-File: deps/libvterm/LICENSE
22
+ Requires-Dist: pyte>=0.8.2
23
+ Requires-Dist: pywinpty>=3.1; sys_platform == "win32"
24
+ Requires-Dist: psutil>=7; sys_platform != "linux"
25
+ Provides-Extra: stats
26
+ Requires-Dist: psutil>=7; extra == "stats"
27
+ Dynamic: license-file
28
+
29
+ # py-screen
30
+
31
+ **A terminal multiplexer in Python, in the family of GNU screen and tmux:** windows and
32
+ splits that keep running when you detach, and a server you can restart, with new code or a
33
+ new config, without losing a single shell.
34
+
35
+ ![A window floods two million lines, a split runs a clock, the window list opens, and `pys reload` restarts the brain while the clock keeps ticking](https://raw.githubusercontent.com/congzhangzh/py-screen/main/docs/demo.v2.gif)
36
+
37
+ With AI agents: `pys-agents` shows which window runs which Claude Code session, and after
38
+ `pys kill` (or a crash) `pys-sessions restore` opens them all again, each with its session and
39
+ folder ([For AI agents](#for-ai-agents)):
40
+
41
+ ![Two agents in pys windows; pys-agents lists them; pys kill ends everything; pys-sessions restore opens both again, each with its session and folder](https://raw.githubusercontent.com/congzhangzh/py-screen/main/docs/demo-agents.v3.gif)
42
+
43
+ A terminal workspace of my own, in Python: not a clone of screen or tmux, built for fun,
44
+ made more powerful and improved by daily use. The design is in [`spec/`](https://github.com/congzhangzh/py-screen/tree/main/spec/); the
45
+ current version is [`spec/spec.v3.md`](https://github.com/congzhangzh/py-screen/blob/main/spec/spec.v3.md).
46
+
47
+ ```bash
48
+ uv tool install py-screen # the command is pys; uv fetches Python 3.14 if needed
49
+ pys # attach, starting the server on first use
50
+ ```
51
+
52
+ No uv or Python? Each [release](https://github.com/congzhangzh/py-screen/releases) has a
53
+ ready-to-use bundle per system, with its own Python inside: unpack it and run `bin/pys`.
54
+
55
+ ## Why py-screen
56
+
57
+ - **Restart without losing anything.** A tiny *keeper* owns every pty; the *brain* (screens,
58
+ keys, rendering) is a separate process. `pys reload` (or `C-a R`) starts a new brain with
59
+ new code or config, and your shells, editors and builds never notice.
60
+ - **Nothing is dropped under a flood.** Every byte a window writes is parsed, in order,
61
+ shown or hidden, with backpressure instead of dropping. Apps that redraw by diff, such as
62
+ AI coding agents, stay correct. With its libvterm engine, `seq 1 5000000` takes 1.6 s
63
+ against GNU screen's 13.4 s.
64
+ - **Made to be driven by scripts and agents.** `pys ls --json`, `pys send`, `pys capture`
65
+ and `pys title` (a window names itself, and a rename by hand still wins) make every window
66
+ readable and writable from outside. An app's OSC 52 copy reaches your clipboard.
67
+ For Claude Code there is more: `pys-agents` shows which window runs which session, and
68
+ `pys-sessions` brings them all back after pys died (see [For AI agents](#for-ai-agents)).
69
+ - **Configured in Python.** `~/.config/pys/config.py` adds status widgets and key bindings
70
+ (`pys sample-config` prints a starting point).
71
+ - **Linux, macOS and Windows** (ConPTY), with wheels for six systems, each one tested on
72
+ its own system before a release.
73
+
74
+ ## Quick start
75
+
76
+ | Do | Keys (prefix `C-a`) |
77
+ |---|---|
78
+ | new window / split side by side / split stacked | `c` / `\|` / `-` |
79
+ | next / previous / pick from a list | `n` / `p` / `w` |
80
+ | scroll back | mouse wheel, or `Shift-PgUp` |
81
+ | copy mode | `[` (search with `/`, select with `v`, copy with `y`) |
82
+ | detach, then attach again | `d`, then `pys` |
83
+ | every key | `?` |
84
+
85
+ **Status:** milestone M1, used every day by its author on Linux; macOS and Windows are
86
+ tested on CI. It is young: tmux and GNU screen do much more (sessions by name, many
87
+ clients with their own views, a huge set of options). See [Known gaps](#known-gaps-m1).
88
+
89
+ ## Use
90
+
91
+ ```bash
92
+ uv tool install -e . # puts `pys` in ~/.local/bin, running this checkout (edits apply on reload)
93
+ pys # attach (starts the server on first use)
94
+ pys ls [--json] # windows
95
+ pys new [--select] [-- cmd …]
96
+ pys send 0 'make test' --enter
97
+ pys capture 0 # the window's screen as text
98
+ pys title 'release · kit · #12 libfuse' # name the window this runs in ($PYS_WINDOW)
99
+ pys title --wid 3 --clear # or another one; no text: print it
100
+ pys title --owner $PPID 'review · pys' # dropped when that process ends
101
+ pys reload # new brain, same shells
102
+ pys kill # end everything
103
+ ```
104
+
105
+ A window shows the first title that is set, in this order: one set by hand (prefix `,`, or
106
+ a `BIND` entry, saved with the layout), then one set by a program with `pys title` (also
107
+ kept across a reload), then the app's own (OSC 0/2), then the command's name. So an agent
108
+ can name its window, the app's spinner titles don't cover it, and a rename by hand still
109
+ wins; renaming to nothing shows the agent's title again. `pys ls --json` gives all three
110
+ per window (`title`, `agent_title`, `app_title`) next to the `name` it shows. A `pys
111
+ title` is one line of printable text, at most 200 characters.
112
+
113
+ An agent title outlives the agent that set it unless it names an owner: with `--owner PID`
114
+ the brain drops the title once that process is gone (checked every 2 s; on Linux by the
115
+ process's start time too, so a reused PID doesn't count). In Claude Code's Bash tool
116
+ `$PPID` is `claude`, so `--owner $PPID` ties the title to the session. Without `--owner`
117
+ it stays until cleared or the window closes. The owner is a PID as the brain sees it, so
118
+ it means nothing from inside a PID namespace (a sandbox with its own PIDs).
119
+
120
+ Keys: the prefix is **`C-a`**, or **`C-]`** when you run `pys` inside GNU screen (`$STY`
121
+ set), so the two don't fight. Override with `PREFIX` in the config or `PYS_PREFIX`.
122
+
123
+ | Keys | Action |
124
+ |---|---|
125
+ | `c` / `C-c` | new window in the focused window's directory |
126
+ | `\|` / `-` | split side by side / stacked, with a new window in the same dir |
127
+ | `\` / `_` | empty split side by side / stacked |
128
+ | `h` `j` `k` `l` | move the focus between panes |
129
+ | `r` then `h/l/j/k`, `=` | resize mode (any other key ends it) · `=` equalise panes |
130
+ | `x` / `X` | close the pane (window keeps running) / kill the window |
131
+ | `n` `p` `0`–`9`, `Alt-0`–`Alt-9` | next, previous, select |
132
+ | `Space` or the prefix twice | toggle to the last window |
133
+ | `w` `"` `F2` | window list (`j/k`, arrows, digits, Enter, Esc) |
134
+ | `,` | rename the window |
135
+ | `S` | hide / show the widgets on the right of the status line, so the window tabs get the whole line (kept across a reload; `SHOW_WIDGETS = False` in the config starts hidden) |
136
+ | `?` | help: the windows (a digit or a click selects one), these keys, your config's bindings; Space pages when it does not fit |
137
+ | `a` | send a literal prefix |
138
+ | `d` · `C-l` · `R` · `Q` | detach · redraw · reload the brain · quit everything |
139
+
140
+ Without the prefix: `Alt-0`…`Alt-9` select, `F2` opens the list, **`Alt-Tab` /
141
+ `Alt-Shift-Tab`** go to the next / previous window. Desktops usually keep Alt-Tab for
142
+ themselves (GNOME: `org.gnome.desktop.wm.keybindings switch-applications`), so it reaches
143
+ py-screen only where the desktop lets it through, e.g. over ssh from another machine.
144
+ Inside GNU screen, screen's own `bindkey` lines may also take the Alt keys first.
145
+
146
+ ### Mouse
147
+
148
+ On by default, like tmux's `mouse on`:
149
+
150
+ | Mouse | Does |
151
+ |-------|------|
152
+ | click a pane | focus it; its app gets the click too if it asked for the mouse |
153
+ | click a window in the status line | select it |
154
+ | drag a `│` separator or a caption line | resize the panes on both sides |
155
+ | wheel, touch drag | goes to the pane under the pointer, focused or not: an app that asked for the mouse gets it; a full-screen app that didn't (less, man) gets arrow keys; anything else (a shell, Claude Code) scrolls back through its history (see Scrollback) |
156
+ | Shift+drag | select text in your terminal (most terminals; the mouse is pys's otherwise) |
157
+
158
+ Every report is routed to the pane under the pointer and rewritten in that pane's
159
+ coordinates, in the encoding the app asked for (SGR 1006, or the old X10 form). A press
160
+ grabs its pane: the drag and the release go there even if the pointer leaves it.
161
+ `MOUSE = False` in the config gives plain drags back to the terminal; the mouse is then
162
+ pys's only while the focused app asks for it (still translated per pane).
163
+
164
+ ### Scrollback
165
+
166
+ Each window keeps the rows that scroll off the top of its screen (`SCROLLBACK`, 5000 by
167
+ default; the alternate screen of full-screen apps keeps none, as in xterm). The wheel or a
168
+ touch drag (a phone's terminal sends it as wheel reports), or Shift-PgUp / Shift-PgDn,
169
+ scroll the pane back and forth through them; the caption shows `[rows back/rows kept]`.
170
+ The view is only a view: the app keeps running and drawing (Claude Code's input box
171
+ included), and output that arrives meanwhile doesn't move the rows you are reading. Any
172
+ key that goes to the app takes the pane back to live. `ESC [ 3 J` (what `clear` sends)
173
+ empties the history. `pys capture --history` prints it.
174
+
175
+ ### Copy mode
176
+
177
+ Prefix `[` (or prefix `Esc`) puts a cursor on the focused pane, over its history and
178
+ screen, as GNU screen and tmux do; the caption says `[copy back/kept]` and the status line
179
+ lists the keys. The pane is frozen as it was at the start, as in tmux: the app keeps
180
+ running and its output is kept, but it shows only when copy mode ends, so an app that
181
+ redraws in place (a spinner, `top`, Claude Code's fullscreen mode) doesn't change the text
182
+ under the cursor. An app on the alternate screen (`alt` in `pys ls --json`) keeps no
183
+ history in pys: copy mode reaches only its one screen, and its own history is the app's
184
+ to scroll (Claude Code: PgUp / PgDn, the wheel).
185
+
186
+ | Keys | Do |
187
+ |------|----|
188
+ | `h j k l`, arrows, wheel | move (the view follows the cursor) |
189
+ | `0 ^ $`, `w b` | line start / first word / end, next / previous word |
190
+ | `C-u C-d`, `C-b C-f`, `PgUp PgDn` | half a page, a page |
191
+ | `g G` | the oldest row, the newest row |
192
+ | `/ ?`, `n N` | search down / up (lower case matches any case), again the same / other way; the match is marked yellow |
193
+ | `v` or `Space` | start or drop a selection |
194
+ | `y` or `Enter` | copy the selection (without one, the cursor's row) and leave |
195
+ | `Esc`, `q`, prefix | drop the selection; leave |
196
+
197
+ A copy is kept for prefix `]`, which types it into the focused window (as a bracketed
198
+ paste when the app asked for one), and is sent to your terminal as OSC 52, which puts it
199
+ on the system clipboard in terminals that allow it (kitty, ghostty, WezTerm, iTerm2,
200
+ Windows Terminal, foot; not GNOME Terminal, and not through a GNU screen or tmux around
201
+ pys). It goes through ssh too, since it is only bytes on the way to your terminal.
202
+
203
+ An app can copy the same way: an OSC 52 from any window, shown or not, is treated as a
204
+ copy, so what Claude Code or vim (over ssh, with a plugin) copies gets to the terminal
205
+ you are attached from. An app can't read the clipboard: pys doesn't answer the OSC 52
206
+ query. A copy over about 750 KB is dropped whole. `Shift+drag` is another path: your
207
+ terminal selects by itself and pys never sees it, so copy with your terminal's own key.
208
+
209
+ `pys ls --json` says per window whether it is in copy mode, how far it is
210
+ scrolled, how much history it keeps, and which mouse mode (`1000`/`1002`/`1003`, `0` =
211
+ none) and screen (`alt`) its app asked for: an app with a mouse mode gets the wheel itself.
212
+
213
+ A row costs about 36 bytes per cell up to its last visible one: a window full of
214
+ 100-column lines holds ~18 MB at 5000 rows. Keeping rows has a price on floods too: about
215
+ 0.2 µs a line (see `bench/README.md`).
216
+
217
+ ### Config
218
+
219
+ `~/.config/pys/config.py` (plain Python, read on every brain start, so `C-a R` applies an
220
+ edit): status widgets, the caption line, extra key bindings. See `src/py_screen/config.py` for
221
+ the helpers.
222
+
223
+ ```
224
+ pys doctor # where it is, and a check: it loads it as the brain does, runs each
225
+ # widget and the CAPTION once (text, time, error), and finds typos
226
+ # (WIDGET for WIDGETS: pys never reads it), an ON_CHANGE not on PATH
227
+ pys doctor --init # write the sample there; never over yours
228
+ pys doctor --reset # keep yours as config.py.<YYYYMMDD-HHMMSS>, then write the sample
229
+ ```
230
+
231
+ **Drop-ins:** `~/.config/pys/config.d/*.py` run after `config.py`, in name order, in the same
232
+ namespace: a file there sets a name (`PREFIX = "C-b"`) or changes what came before
233
+ (`WIDGETS.append(...)`, `BIND["x"] = ...`), so a machine's own lines, or a tool's, live apart
234
+ from the config you share or replace. `pys setup` writes its ON_CHANGE to
235
+ `config.d/50-pys-setup.py` and never edits `config.py`. A drop-in that fails is named by
236
+ `pys doctor` (with its line) and left out; the others still count. Next to a `$PYS_CONFIG` of
237
+ another name, the folder is `<its name>.d`.
238
+
239
+ **The caption line** (the line under each pane: its number and name, then its folder) takes
240
+ `CAPTION` in place of the folder: any object with `.every` and `async update(ctx) -> str`, run
241
+ for each window on screen (`ctx`: `cwd`, `wid`, `pid`), at most 5 s per update.
242
+ `caption()` from `py_screen.agents.widget` shows where the work is: the folder and its git
243
+ branch and changes, and for a window with an agent in it, the folder it started in first, then
244
+ the repo it last wrote in (✎) when that is elsewhere:
245
+
246
+ ```python
247
+ from py_screen.agents.widget import caption
248
+ CAPTION = caption() # " 3 build fix ~/src/app ✎ ~/src/app-fix fix-build wt 1 mod "
249
+ ```
250
+
251
+ It reads: started in `~/src/app` (its Bash goes back there, and a resume runs there), works in
252
+ `~/src/app-fix` (✎), on branch `fix-build`, a linked worktree (`wt`), 1 tracked file
253
+ modified. Also: `3 new` untracked files, `↑2 ↓5` commits ahead of / behind the upstream (as of
254
+ the last fetch), `@1f0e2c1` a detached HEAD; nothing after the branch: clean.
255
+
256
+ ```python
257
+ WIDGETS = [cmd(["git", "-C", "{cwd}", "branch", "--show-current"], every=10), load(), clock()]
258
+ BIND = {"T": ("top", ["htop"])} # C-a T opens a window running htop
259
+ MOUSE = True # False: plain drags select text in the terminal
260
+ SCROLLBACK = 5000 # rows of history per window; 0 = none
261
+ ```
262
+
263
+ `pys sample-config` prints a fuller sample to start from (`pys sample-config >
264
+ ~/.config/pys/config.py`): CPU, memory and network (down/up in kB/s) widgets and a clock
265
+ that ticks on each second, written in the config itself, and `branch()` from
266
+ `py_screen.agents.widget`: the focused window's git branch, worktree and changes, and for a
267
+ window with an agent in it, the repo the agent last wrote in (✎, see
268
+ [For AI agents](#for-ai-agents)). A widget is any object with `.every`, `.text` and
269
+ `async update(ctx) -> str`, so new ones need no change to pys; `ctx` is the focused window:
270
+ `cwd`, `wid` and `pid` (its process).
271
+ CPU, memory, network and disk (read/write in kB/s) read `/proc` on Linux; on macOS and
272
+ Windows they use psutil, which pys installs there.
273
+
274
+ The layout (splits, focus, renamed titles) is saved on every change, so a reload keeps it.
275
+
276
+ ### What the windows were: ON_CHANGE and the server record
277
+
278
+ `pys kill` and prefix `Q` end every window and delete the layout. Two things keep what they
279
+ were, so a tool can bring them back:
280
+
281
+ - **`ON_CHANGE`** in the config, a command (an argv list), run about 2 s after a change (a
282
+ window opens or exits, a title changes, the brain starts) with `PYS_EVENT=change`, and once
283
+ before `pys kill` / `Q` with `PYS_EVENT=kill`, while every window still runs; the kill waits
284
+ for it at most `KILL_HOOK_TIMEOUT` seconds (5), then ends as before. One run at a time
285
+ (changes during a run give one more run after it); `PYS_DIR` is set, so `pys ls --json`
286
+ inside it reaches this session; its output goes to `server.log`.
287
+ - **The server record**: on the same changes and at a kill, the brain writes
288
+ `$XDG_STATE_HOME/pys/servers/<start YYYYMMDD-HHMMSS>-<server pid>.json` (default
289
+ `~/.local/state`, a session with its own `PYS_DIR` too, since the runtime dir is gone at the
290
+ next boot): the server's pid, `started`, `updated`, `ended` (set when it ends by a kill or
291
+ `Q`, with the windows it ended; by its last window, with none; `null` while it runs or if
292
+ it died), and each window's `wid`, `pid`,
293
+ `cmd`, `cwd` (its shell's current directory), `name`, `title`, `agent_title`,
294
+ `agent_owner`. One file per server life, rewritten whole (tmp + rename), never deleted.
295
+
296
+ ```python
297
+ ON_CHANGE = ["pys-sessions", "save", "--quiet"] # comes with pys (below), or a script of
298
+ # your own: an argv list
299
+ KILL_HOOK_TIMEOUT = 5
300
+ ```
301
+
302
+ Sockets live in `$XDG_RUNTIME_DIR/pys/` (`PYS_DIR` overrides; keep it short: an AF_UNIX
303
+ path is limited to 108 bytes). Server log: `server.log` in that dir.
304
+
305
+ Each window gets `PYS_WINDOW` (its number) and `PYS_SESSION` (its server's dir). `pys`
306
+ typed in a window of the same session refuses to attach: the session would show inside
307
+ itself and the keys would loop between the window and the server. Another session
308
+ (`PYS_DIR=<dir> pys`) may be nested; give it its own prefix (`PYS_PREFIX`).
309
+
310
+ ## For AI agents
311
+
312
+ Agents such as Claude Code often run in pys windows, several at a time. pys comes with two
313
+ commands for them and two skills that teach an agent to use pys well. They are built only on
314
+ the commands above, so the core knows nothing of them. Claude Code for now (Codex later);
315
+ Linux, macOS and Windows (processes: `/proc` on Linux, psutil elsewhere).
316
+
317
+ **All of it in one step**, the same for uvx, pip, a uv tool and the bundle:
318
+
319
+ ```bash
320
+ pys setup # what it would do
321
+ pys setup --yes # do it; running it again is safe
322
+ ```
323
+
324
+ 1. a config: the sample when you have none, with `ON_CHANGE` snapshots on (`pys-sessions
325
+ save`, below); a config of your own is kept, a copy first, and `ON_CHANGE` is added only
326
+ when it has none;
327
+ 2. the skills `pys-drive` and `pys-restore`, linked into Claude Code's `skills` folder;
328
+ 3. a `SessionStart` hook in Claude Code's `settings.json` (a copy of it first): every claude
329
+ that starts in a pys window is told, in a few lines, that it is in pys, which window, how
330
+ to name it, how to look before it types, never to `pys kill`, and how to run pys from this
331
+ install; outside pys it says nothing. `python -m py_screen.agents.instructions --show`
332
+ prints the text. A skill is read when a task calls for it; these lines are always there.
333
+
334
+ ![Two agents in pys windows; pys-agents lists them; pys kill ends everything; pys-sessions restore opens both again, each with its session and folder](https://raw.githubusercontent.com/congzhangzh/py-screen/main/docs/demo-agents.v3.gif)
335
+
336
+ (The agents in the demo are stand-ins that write what Claude Code writes; it is recorded in a
337
+ container with `docs/record-demo-agents.sh`.)
338
+
339
+ **`pys-agents`**: every running claude, the window it runs in, and how it is doing:
340
+
341
+ ```
342
+ $ pys-agents
343
+ WIN TITLE STATE CTX COMPACT MODEL NAME FOLDER
344
+ 0 review PR 42 idle 237k 0 opus-5-5 reviewer ~/src/app
345
+ 2 build fix busy 844k 1 opus-5-5 builder ~/src/app
346
+ - - idle 12k 0 opus-5-5 - ~/notes
347
+ ```
348
+
349
+ CTX is what the last call held (input, cache read and written, from the transcript), COMPACT
350
+ the compactions so far; a claude outside pys shows window `-`. `--json` gives one object per
351
+ claude (`window`, `pid`, `session`, `name`, `state`, `cwd`, `context_tokens`, `model`,
352
+ `compactions`, and `titled_by_it` when it named its window with `pys title --owner`).
353
+
354
+ **`pys-agents where`**: every window, agent or not, with the git state of its folder:
355
+
356
+ ```
357
+ $ pys-agents where
358
+ WIN TITLE FOLDER BRANCH STATE AGENT
359
+ 0 review PR 42 ~/src/app main @a96cd56 clean ↑2 claude reviewer (idle)
360
+ 1 build fix ✎ ~/src/app-fix fix-build @1f0e2c1 (wt) 1 mod 3 new claude builder (busy)
361
+ 2 - ~/notes - - -
362
+ ✎ = the repo of the agent's latest writes (its transcript), not the folder it started in
363
+ (wt) = a linked worktree (git worktree add) of that repo
364
+ ```
365
+
366
+ An agent's folder is not where it works: Claude Code keeps the folder it was started in, its
367
+ tools take absolute paths anywhere, and its Bash shell goes back to the start folder after
368
+ every command. So for an agent, `where` looks at what it did: the files it last wrote (Write,
369
+ Edit) and the `cd <dir> && …` its Bash commands start with, newest first, and shows the first
370
+ one inside a git repo (✎). A Read is not work, and a folder outside any repo is skipped.
371
+ `--json` keeps both: `cwd` (where it started) and `works_in`.
372
+
373
+ STATE is `git status` against the upstream (↑ ahead, ↓ behind, as of the last fetch); git runs
374
+ with `--no-optional-locks`, so it never takes the index lock from under you. `--json` gives
375
+ `git: {repo, top, common, worktree, branch, head, changed, untracked, ahead, behind}` per
376
+ window.
377
+
378
+ **`pys-sessions`**: keep what every window is, and open it all again after pys died (a
379
+ kill, a crash, a reboot):
380
+
381
+ ```python
382
+ # ~/.config/pys/config.py
383
+ ON_CHANGE = ["pys-sessions", "save", "--quiet"] # a snapshot at each change and before a kill
384
+ SESSIONS_KEEP_ENV = [] # variables of a claude's environment its resume gets back
385
+ ```
386
+
387
+ | Command | What it does |
388
+ |---|---|
389
+ | `pys-sessions save` | snapshot each window: folder, titles, git repo / branch / HEAD / worktree, the Claude Code session in it with the flags it was started with, the last 3000 lines of its screen; for an agent, also where it worked (`works_in`, as in `where`); only when something changed, or every 2 h |
390
+ | `pys-sessions list` / `show [SNAP]` | the snapshots, what one holds |
391
+ | `pys-sessions restore [SNAP] [--only W,W] [--yes]` | a dry run, then each window again: `claude <its flags> --resume <id>`, or a shell in its folder; title `↺<old title>` until the agent sets its own. The dry run says which branch each window was on, and when a worktree's folder is gone since, the `git worktree add` that brings it back |
392
+ | `pys-sessions forensic [--write]` | no snapshot of the dead server? rebuild it from its server record (folders, titles) and from what Claude Code leaves behind (sockets, transcripts), with a `why` per window |
393
+ | `pys-sessions install` | a systemd user timer running `save` every 2 minutes, besides ON_CHANGE (Linux; on macOS and Windows ON_CHANGE alone does it) |
394
+
395
+ Snapshots go to `~/.local/state/pys-sessions/<YYYYMMDD-HHMMSS>/` (`PYS_SESSIONS_DIR`) and are
396
+ never deleted or overwritten.
397
+
398
+ **Skills.** `pys-agents skills --install` links them into `~/.claude/skills` (one of the same
399
+ name that is already there is left alone; a link to a pys install that is gone is renewed; on
400
+ Windows, without the right to make a symlink, a directory junction). A link points into the
401
+ install that ran the command, so run it from one you keep (`uv tool install`, pip, a bundle):
402
+ a `uvx` run lives in uv's cache, which `uv cache clean` empties.
403
+
404
+ - `pys-drive`: name your window, find the other sessions, read a window, type into one only
405
+ when it is idle (text, then Enter on its own: an Enter inside a paste is a new line), never
406
+ `pys kill`, test pys on a second server.
407
+ - `pys-restore`: bring the windows back after pys died, step by step, showing the plan first.
408
+
409
+ Both commands read Claude Code's own files (`~/.claude/sessions/<pid>.json`, the transcripts
410
+ under `~/.claude/projects/`; `CLAUDE_CONFIG_DIR` moves them). Those are not a documented
411
+ interface, so the reads are defensive: after a Claude Code update a field may show `-`
412
+ until pys catches up.
413
+
414
+ ## How it works
415
+
416
+ ```
417
+ client (pys attach) ──► brain (Python, restartable) ──► keeper (tiny, stable) ──► ptys
418
+ ```
419
+
420
+ - **keeper** owns every pty and child process and is the only reader of their output
421
+ (a byte pump with a ring buffer per window). It restarts the brain when it exits.
422
+ - **brain** holds a pyte screen per window, renders the current one plus a status line,
423
+ handles keys and answers RPCs. `pys reload` restarts it; shells never notice, because
424
+ the ptys live in the keeper. The new brain replays the ring buffers, then nudges each pty
425
+ (SIGWINCH) so full-screen apps repaint.
426
+ - **client** puts the terminal in raw mode, forwards keys and size, prints frames, and
427
+ reconnects by itself across a brain reload.
428
+
429
+ ### Floods: the screen model is lossless
430
+
431
+ Every byte a window writes is parsed, in order, whether the window is shown or hidden,
432
+ attached or not. That matters most for apps that draw by diff, like Claude Code: they
433
+ write only the cells they think changed and never rewrite a cell they think is right, so
434
+ a single lost byte leaves wrong cells for good (and a lost mode switch, such as the
435
+ alternate screen or bracketed paste, breaks input too). Until 2026-11-01 py-screen parsed
436
+ only the tail of a flood and dropped a hidden window's output past 128 KB; that is what
437
+ left stray characters in Claude's status line.
438
+
439
+ Parsing is the slow part (pyte in Python; py-screen's fast paths for plain text, line
440
+ feeds and CSI sequences take it from 0.85 to 1.7 MB/s of scrolling text), so:
441
+
442
+ - **Backpressure, not dropping.** The keeper gives each window a **credit** of 256 KB
443
+ un-acked output and stops reading that pty past it. The brain acks bytes only once it
444
+ has parsed them, so a flooding app simply waits on its write, as under GNU screen. One
445
+ window can't fill the shared stream, so another window's echo never waits behind it.
446
+ - **Small steps.** The brain parses in steps of 2 ms (2 KB per window per turn, shown
447
+ windows first, the others round robin), then goes back to keys and frames.
448
+ - **Keys first.** For 100 ms after a key, only shown windows are parsed: nothing else
449
+ stands between a key and its echo (about 2 ms median while a hidden window floods).
450
+
451
+ A flood runs at the parser's speed. With the vterm engine (libvterm, in C) that is faster
452
+ than GNU screen: `seq 1 5000000` takes 1.6 s against screen's 13.4 s, a 50 MB coloured log
453
+ 0.9 s against 8.1 s ([`bench/README.md`](https://github.com/congzhangzh/py-screen/blob/main/bench/README.md) has the history).
454
+
455
+ **Screen engines.** Each window's screen is a `term.Term`; `PYS_TERM` picks the engine:
456
+
457
+ | Engine | What | When |
458
+ |---|---|---|
459
+ | `vterm` (default) | [libvterm](https://github.com/congzhangzh/py-screen/blob/main/deps/README.md)'s parser and state, compiled in, with py-screen's own screen store | When the C extension was built (needs a C compiler at install; release wheels have it) |
460
+ | `pyte` | [pyte](https://github.com/selectel/pyte) with pure-Python fast paths | Always there; about 20× slower on floods |
461
+
462
+ `PYS_TERM=vterm` makes a missing extension an error instead of a quiet fallback.
463
+
464
+ A sequence that pyte can't apply (an extra parameter, a `?` where none is defined, an
465
+ unknown erase value) is ignored, as terminals do; the bytes after it are still parsed.
466
+
467
+ ## Releases
468
+
469
+ A tag is a release. Pushing a tag `<YYYYMMDD>.<N>` on a commit of `main` publishes that
470
+ version on [PyPI as `py-screen`](https://pypi.org/project/py-screen/) and as a
471
+ [GitHub release](https://github.com/congzhangzh/py-screen/releases), once, never replaced:
472
+
473
+ ```bash
474
+ git tag 20261106.1 <sha> && git push origin 20261106.1
475
+ ```
476
+
477
+ Every push, on every branch, and every pull request runs the same tests, in
478
+ [`ci.yml`](https://github.com/congzhangzh/py-screen/blob/main/.github/workflows/ci.yml), and
479
+ publishes nothing. A release runs them again, then builds, in
480
+ [`release.yml`](https://github.com/congzhangzh/py-screen/blob/main/.github/workflows/release.yml),
481
+ and must pass:
482
+
483
+ - the suite on Linux and macOS, and the RPC smoke test on Windows, on both screen engines
484
+ (vterm and pyte);
485
+ - the vterm engine built with ASan + UBSan and fuzzed against libvterm's own screen
486
+ (a long run every night, in [`sanitize.yml`](https://github.com/congzhangzh/py-screen/blob/main/.github/workflows/sanitize.yml));
487
+ - a parse-speed check: vterm's lead over pyte must keep at least half of the newest
488
+ recorded run's (`release/bench_gate.py`, against `bench/results/parse-*.json`);
489
+ - each wheel, built by cibuildwheel from the sdist, installed and smoke-tested on the vterm
490
+ engine on its own system: Linux x86_64 and aarch64 (manylinux), macOS arm64 and x86_64,
491
+ Windows x86_64 and ARM64;
492
+ - a ready-to-use bundle per system (below), built from that wheel, unpacked into another
493
+ folder and smoke-tested there with no uv or Python on `PATH`.
494
+
495
+ Install with [uv](https://docs.astral.sh/uv/), which fetches Python 3.14 if needed and picks
496
+ the wheel for your system:
497
+
498
+ ```bash
499
+ uv tool install py-screen # the command is pys
500
+ ```
501
+
502
+ The import is `py_screen` and the command `pys`. Until 20261109.2 the package was
503
+ `py-screen-mux` (an older `pyscreen` held the name); that name gets no more releases, so
504
+ `uv tool upgrade py-screen-mux` stays at 20261109.2. To move to the new name:
505
+ `uv tool uninstall py-screen-mux && uv tool install py-screen`, then `pys setup --yes` (it
506
+ points the skills and the hook at the new install).
507
+
508
+ Or take the **ready-to-use bundle** for your system from the
509
+ [release](https://github.com/congzhangzh/py-screen/releases): `pys-<version>-<os>-<arch>.tar.gz`
510
+ (`.zip` on Windows) holds its own Python 3.14, pys and its dependencies (pyte, psutil), so
511
+ there is nothing to install and no network needed. Unpack it anywhere and run `bin/pys`
512
+ (`pys.exe` on Windows; `pys-agents` and `pys-sessions` beside it); the folder still works after you move it. `release/bundle.py` builds
513
+ it: python-build-standalone's CPython (the one uv installs) with the release's wheel
514
+ installed into it, a launcher in place of pip's scripts (those hold an absolute path), and
515
+ Tk, IDLE and the C headers left out (about 35 MB packed). On macOS, a browser download is
516
+ quarantined: `xattr -dr com.apple.quarantine pys-*` before the first start.
517
+
518
+ Elsewhere, the sdist builds with a C compiler, or runs the pure-Python pyte engine without one.
519
+
520
+ ## Test and measure
521
+
522
+ ```bash
523
+ uv run pytest -s # M0 done-criteria (spec §6)
524
+ uv run python bench/m0_speed.py --work <scratch dir> # speed vs GNU screen (spec §8.1)
525
+ ```
526
+
527
+ Results of each run are kept in [`bench/results/`](https://github.com/congzhangzh/py-screen/tree/main/bench/results/), one file per run, and [`bench/README.md`](https://github.com/congzhangzh/py-screen/blob/main/bench/README.md) tells how the speed evolved, one row per run.
528
+
529
+ ## Platforms
530
+
531
+ Everything that differs between systems lives in
532
+ [`src/py_screen/platform/`](src/py_screen/platform/) (spec §5.2); the keeper, brain and
533
+ client import nothing else that is OS-specific.
534
+
535
+ | | Linux / macOS | Windows |
536
+ |--|--|--|
537
+ | ptys | `pty.fork` | ConPTY via [pywinpty](https://github.com/andfoy/pywinpty) |
538
+ | keeper ↔ brain ↔ client | Unix sockets (0600) in `$XDG_RUNTIME_DIR/pys/` | named pipes `\\.\pipe\pys-<user>-<dir hash>-*` |
539
+ | background server | `setsid` | hidden console (`CREATE_NO_WINDOW`) |
540
+ | attached terminal | raw tty, SIGWINCH | console VT mode, size polled every 250 ms |
541
+ | default shell | `$SHELL` | `%PYS_SHELL%`, else `%COMSPEC%` |
542
+
543
+ CI: every push runs the suite on Linux and macOS and `tests/test_rpc_smoke.py` on Windows.
544
+ `spike/win_check.py` runs too when the platform layer changes, or by hand (`platform`
545
+ workflow).
546
+
547
+ ## Known gaps (M1)
548
+
549
+ - A mouse report split across two reads is passed on as keys (rare; the outer terminal
550
+ writes each report at once).
551
+ - After a brain restart the screens are rebuilt from the keeper's last 2 MB per window; if
552
+ that ring wrapped, older state is gone and full-screen apps get a `SIGWINCH` to repaint.
553
+ Keeping the screens across a planned reload is open.
554
+ - Floods are bounded by pyte's speed (see "Floods").
555
+ - The scrollback isn't rewrapped on a resize; copy mode selects character-wise only
556
+ (no line or block selection), and the paste buffer is lost on a brain reload.
557
+ - Windows: `pys attach` needs a real console (Windows Terminal or conhost, not mintty);
558
+ a new window doesn't inherit the focused window's directory (it isn't read from the
559
+ child yet), and the load widget is empty. The pty harness tests are POSIX-only, so
560
+ Windows CI runs the RPC smoke test.
561
+ - Windows named pipes keep the default DACL (other local users may open them); instead
562
+ every connection must first send a per-session key from the user-private runtime dir.
563
+ A per-user DACL would close the pipe itself; still to do.
564
+
565
+ ## Contributing
566
+
567
+ Issues with a way to reproduce them are welcome, and so are pull requests. To work on it:
568
+
569
+ ```bash
570
+ git clone https://github.com/congzhangzh/py-screen && cd py-screen
571
+ uv tool install -e . # pys runs this checkout; `pys reload` loads your edits
572
+ uv run pytest # the suite, about a minute
573
+ ```
574
+
575
+ Every push and pull request runs the suite on Linux and macOS and the RPC smoke test on Windows
576
+ ([`ci.yml`](https://github.com/congzhangzh/py-screen/blob/main/.github/workflows/ci.yml)).
577
+ A commit's subject says what changed for a user; it is what the release notes list.
578
+ The demo above is recorded from [`docs/demo.tape`](https://github.com/congzhangzh/py-screen/blob/main/docs/demo.tape)
579
+ with [vhs](https://github.com/charmbracelet/vhs): `docs/record-demo.sh` runs it in a clean
580
+ environment, as a made-up user in a session of its own. A new take gets a new version
581
+ (`demo.v3.gif`) beside the old one.
582
+
583
+ ## License
584
+
585
+ MIT ([`LICENSE`](https://github.com/congzhangzh/py-screen/blob/main/LICENSE)). The bundled
586
+ libvterm is MIT as well ([`deps/libvterm/LICENSE`](https://github.com/congzhangzh/py-screen/blob/main/deps/libvterm/LICENSE)).