ghostty-textual 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.
Files changed (43) hide show
  1. ghostty_textual-0.1.0/.gitignore +10 -0
  2. ghostty_textual-0.1.0/LICENSE +21 -0
  3. ghostty_textual-0.1.0/PKG-INFO +210 -0
  4. ghostty_textual-0.1.0/README.md +183 -0
  5. ghostty_textual-0.1.0/benchmarks/feed_bursts.py +58 -0
  6. ghostty_textual-0.1.0/benchmarks/frame_extraction.py +114 -0
  7. ghostty_textual-0.1.0/benchmarks/widget_rendering.py +40 -0
  8. ghostty_textual-0.1.0/docs/performance.md +81 -0
  9. ghostty_textual-0.1.0/docs/superpowers/plans/2026-07-25-ghostty-textual-library.md +1986 -0
  10. ghostty_textual-0.1.0/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md +901 -0
  11. ghostty_textual-0.1.0/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md +449 -0
  12. ghostty_textual-0.1.0/docs/superpowers/specs/sol-5.6-textual-design-review.md +795 -0
  13. ghostty_textual-0.1.0/pyproject.toml +78 -0
  14. ghostty_textual-0.1.0/src/ghostty_textual/__init__.py +69 -0
  15. ghostty_textual-0.1.0/src/ghostty_textual/_native.py +277 -0
  16. ghostty_textual-0.1.0/src/ghostty_textual/_render.py +440 -0
  17. ghostty_textual-0.1.0/src/ghostty_textual/cells.py +111 -0
  18. ghostty_textual-0.1.0/src/ghostty_textual/emulator.py +832 -0
  19. ghostty_textual-0.1.0/src/ghostty_textual/keys.py +51 -0
  20. ghostty_textual-0.1.0/src/ghostty_textual/py.typed +1 -0
  21. ghostty_textual-0.1.0/src/ghostty_textual/theme.py +68 -0
  22. ghostty_textual-0.1.0/src/ghostty_textual/widget.py +682 -0
  23. ghostty_textual-0.1.0/tests/__init__.py +0 -0
  24. ghostty_textual-0.1.0/tests/conftest.py +157 -0
  25. ghostty_textual-0.1.0/tests/test_abi.py +133 -0
  26. ghostty_textual-0.1.0/tests/test_cells.py +43 -0
  27. ghostty_textual-0.1.0/tests/test_contract_regressions.py +149 -0
  28. ghostty_textual-0.1.0/tests/test_effects.py +63 -0
  29. ghostty_textual-0.1.0/tests/test_emulator.py +194 -0
  30. ghostty_textual-0.1.0/tests/test_frame_scheduling.py +135 -0
  31. ghostty_textual-0.1.0/tests/test_fuzz.py +27 -0
  32. ghostty_textual-0.1.0/tests/test_packaging.py +68 -0
  33. ghostty_textual-0.1.0/tests/test_performance.py +61 -0
  34. ghostty_textual-0.1.0/tests/test_render.py +92 -0
  35. ghostty_textual-0.1.0/tests/test_render_optimizations.py +163 -0
  36. ghostty_textual-0.1.0/tests/test_reset.py +111 -0
  37. ghostty_textual-0.1.0/tests/test_resize.py +60 -0
  38. ghostty_textual-0.1.0/tests/test_security.py +147 -0
  39. ghostty_textual-0.1.0/tests/test_sync_output.py +53 -0
  40. ghostty_textual-0.1.0/tests/test_widget.py +302 -0
  41. ghostty_textual-0.1.0/tests/test_writer_lifecycle.py +120 -0
  42. ghostty_textual-0.1.0/tests/widget_harness.py +61 -0
  43. ghostty_textual-0.1.0/uv.lock +392 -0
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .coverage
10
+ .ayder/evidence/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ghostty-textual contributors
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,210 @@
1
+ Metadata-Version: 2.5
2
+ Name: ghostty-textual
3
+ Version: 0.1.0
4
+ Summary: Textual terminal widget backed by libghostty-vt
5
+ Project-URL: Homepage, https://github.com/ayder/ghostty-textual
6
+ Project-URL: Documentation, https://github.com/ayder/ghostty-textual#readme
7
+ Project-URL: Source, https://github.com/ayder/ghostty-textual
8
+ Project-URL: Issues, https://github.com/ayder/ghostty-textual/issues
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Topic :: Terminals :: Terminal Emulators/X Terminals
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: cffi>=2.1.1; python_version >= '3.15'
19
+ Requires-Dist: pyghostty==0.1.4
20
+ Requires-Dist: textual<9,>=8.2.8
21
+ Provides-Extra: dev
22
+ Requires-Dist: packaging>=24.2; extra == 'dev'
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
24
+ Requires-Dist: pytest>=8.0; extra == 'dev'
25
+ Requires-Dist: ruff>=0.8; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ghostty-textual
29
+
30
+ A [Textual](https://textual.textualize.io/) terminal widget backed by
31
+ [libghostty-vt](https://libghostty.tip.ghostty.org/), Ghostty's embeddable
32
+ terminal emulation core.
33
+
34
+ The headless emulator and `TerminalView` widget are implemented. The public
35
+ renderer uses libghostty's render-state and cell accessor APIs, keeps Ghostty's
36
+ viewport authoritative, and routes terminal replies and user input through one
37
+ ordered transport queue.
38
+
39
+ ## Why
40
+
41
+ Existing Python terminal emulators force a choice: `pyte` is unmaintained and
42
+ structurally unprepared for modern escape sequences, while newer pure-Python
43
+ parsers are young enough that their own scrollback support is still in progress.
44
+ `libghostty-vt` is neither — it is Ghostty's shipping core, extracted as a
45
+ zero-dependency C library with scrollback, reflow, and grapheme handling built
46
+ in.
47
+
48
+ ## Design principle
49
+
50
+ **The library never owns a process, PTY, or transport.** Bytes in, bytes out.
51
+
52
+ That constraint is what makes it usable by applications that already own their
53
+ process lifecycle — SSH clients with password injection and process-group
54
+ cleanup, for example — and it is why existing batteries-included Textual
55
+ terminal widgets were not an option.
56
+
57
+ Two layers, one native boundary:
58
+
59
+ | Module | Role |
60
+ |---|---|
61
+ | `_native.py` | Loader, ABI verification, union-by-value shims. The only file that knows `pyghostty` exists. |
62
+ | `_render.py` | Private public-ABI render-state extraction and dirty acknowledgement. |
63
+ | `emulator.py` | `Terminal` — headless, no Textual import. Absorbs libghostty API churn. |
64
+ | `widget.py` | `TerminalView` — rendering, input, selection. No C knowledge. |
65
+
66
+ ## Minimal use
67
+
68
+ Install the package from PyPI:
69
+
70
+ ```bash
71
+ pip install ghostty-textual
72
+ ```
73
+
74
+ The distribution name is `ghostty-textual`; the Python import is `ghostty_textual`.
75
+
76
+ ```python
77
+ from ghostty_textual import TerminalView
78
+
79
+ view = TerminalView(
80
+ send=session.send,
81
+ resize_transport=lambda cols, rows: session.resize(rows, cols),
82
+ )
83
+ session.on_output = view.feed
84
+ ```
85
+
86
+ `feed()` parses bytes and queues replies immediately, then coalesces frame
87
+ extraction until the next event-loop turn. Call `view.refresh_frame()` after
88
+ feeding when you need to inspect the rendered frame synchronously. Synchronized
89
+ output still withholds frames until release or timeout; `refresh_frame(force=True)`
90
+ explicitly bypasses that hold.
91
+
92
+ The application remains responsible for process and PTY lifecycle. For
93
+ reconnects, close the old process, `await view.reset_io()`, call
94
+ `view.hard_reset()`, then start the replacement process.
95
+
96
+ ## Design documents
97
+
98
+ - [`docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md)
99
+ — current design
100
+ - [`docs/superpowers/specs/sol-5.6-textual-design-review.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/sol-5.6-textual-design-review.md)
101
+ — review that produced it
102
+ - [`docs/superpowers/specs/2026-07-25-ghostty-textual-design.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md)
103
+ — superseded v1
104
+
105
+ ## Supported platforms
106
+
107
+ Requires Python 3.12+ and pins `pyghostty==0.1.4`, which bundles libghostty-vt
108
+ built for baseline CPUs. No native compilation is needed to install it.
109
+
110
+ | OS | Architectures | Minimum OS / libc |
111
+ |---|---|---|
112
+ | Linux | `amd64` / `x86_64`, `aarch64` / `arm64` | glibc 2.27 |
113
+ | macOS | `amd64` / `x86_64` (Intel), `arm64` / `aarch64` (Apple Silicon) | macOS 13 |
114
+
115
+ `amd64` and `x86_64` name the same architecture; so do `aarch64` and `arm64`.
116
+ Use a 64-bit OS and Python on ARM devices such as Raspberry Pi 5. Upstream
117
+ does not publish Windows, musl/Alpine Linux, or 32-bit ARM wheels.
118
+
119
+ CI runs the native ABI and functional tests, plus a clean wheel installation
120
+ and terminal smoke test, on Linux and macOS with both CPU architectures using
121
+ Python 3.12. An additional Linux x86_64 job runs the same checks with Python 3.15.
122
+ Python 3.15 requires CFFI 2.1.1 or newer to avoid an upstream callback ABI crash;
123
+ this minimum is enforced by the package dependencies.
124
+
125
+ The bundled native library retains a cell's base codepoint plus at most 64
126
+ additional grapheme codepoints. Further combining codepoints are ignored to
127
+ bound memory and processing costs. This applies across fragmented input too.
128
+ The scheduled ABI drift job tests the latest upstream binding separately from
129
+ the exact dependency used by normal CI and released distributions.
130
+
131
+ ## Develop
132
+
133
+ ```bash
134
+ uv sync --all-extras
135
+ uv run pytest
136
+ uv run ruff check src tests
137
+ ```
138
+
139
+ The full-frame extraction benchmark defaults to a 10 ms p95 budget for a
140
+ 120×40 terminal. GitHub CI enforces 100 ms on Linux and macOS ARM64. Hosted macOS
141
+ Intel records median/p95 and budget violations without failing on timing alone;
142
+ that runner has exceeded 100 ms even on the pre-optimization baseline. Functional,
143
+ memory, ABI, and packaging checks remain mandatory on every platform. Native
144
+ extraction errors still fail the timing step, including on Intel.
145
+
146
+ Set `GHOSTTY_TEXTUAL_FRAME_BUDGET_MS` to override the budget locally. The default
147
+ `GHOSTTY_TEXTUAL_FRAME_BUDGET_MODE=enforce` asserts the p95 budget; `report` records
148
+ timings without asserting the cutoff. CI runs the timing test separately with
149
+ output capture disabled so successful runs also retain measurements in their logs.
150
+
151
+ Additional benchmarks cover styled and linked extraction, row rendering,
152
+ selection, and fragmented input through a mounted widget:
153
+
154
+ ```bash
155
+ uv run python benchmarks/frame_extraction.py
156
+ uv run python benchmarks/widget_rendering.py
157
+ uv run python benchmarks/feed_bursts.py
158
+ ```
159
+
160
+ See [performance measurements and cache design](https://github.com/ayder/ghostty-textual/blob/main/docs/performance.md) for results
161
+ and measurement scope.
162
+
163
+ ## Releasing
164
+
165
+ Update the version in `pyproject.toml` and `uv.lock`, merge the release PR, then
166
+ push a matching tag such as `v0.1.0`. The tag must point to the merged commit that
167
+ contains that version and the release workflow.
168
+
169
+ CI scans the full Git history with Gitleaks, then requires that scan and the
170
+ four-platform test matrix to pass before releasing. It also checks distribution
171
+ metadata and README rendering with `twine check --strict`.
172
+
173
+ CI builds and smoke-tests distributions on all four supported platforms. The
174
+ Linux x86_64 job retains its wheel and source archive as the `python-dist` Actions
175
+ artifact on every run. After all platform jobs pass on a `v*` tag push, the release
176
+ job verifies the tag, package version, filenames, and archive metadata, then
177
+ attaches the tested files to a GitHub Release. It creates a draft when needed and
178
+ publishes only after the uploads succeed; an existing release keeps its notes.
179
+ A separate job then publishes the same tested artifacts to PyPI as
180
+ `ghostty-textual` through Trusted Publishing, with provenance attestations.
181
+
182
+ The wheel is `ghostty_textual-<version>-py3-none-any.whl`: one wheel supports all
183
+ the listed platforms, with the native binary supplied by the pinned `pyghostty`
184
+ dependency. The source archive is `ghostty_textual-<version>.tar.gz`.
185
+
186
+ Retry a failed release job from Actions. Already uploaded assets are downloaded
187
+ and compared before being reused; differing files fail rather than replacing a
188
+ published asset. Only the GitHub release job receives `contents: write`; only the
189
+ PyPI job receives `id-token: write`. The PyPI job downloads the tested artifact
190
+ without checking out or building repository code. No PyPI API token is stored.
191
+
192
+ Before the first PyPI release:
193
+
194
+ 1. Create the GitHub Actions environment `pypi` in `ayder/ghostty-textual`.
195
+ Restrict its deployment tags to `v*`; add required reviewers if desired.
196
+ 2. In the intended owner's [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
197
+ add a pending Trusted Publisher with project name `ghostty-textual`, owner
198
+ `ayder`, repository `ghostty-textual`, workflow `ci.yml`, and environment `pypi`.
199
+ For an existing project, add the publisher in that project's publishing settings.
200
+ 3. Merge the release PR and push `v0.1.0`. PR and branch builds validate packages
201
+ but do not upload to either registry. A failed PyPI job can be rerun after
202
+ fixing publisher configuration. Already published versions cannot be replaced;
203
+ use a new version for subsequent releases.
204
+
205
+ For a local secret scan, install [Gitleaks](https://github.com/gitleaks/gitleaks)
206
+ and run `gitleaks git --redact --no-banner --log-opts=--all .`.
207
+
208
+ ## Licence
209
+
210
+ MIT
@@ -0,0 +1,183 @@
1
+ # ghostty-textual
2
+
3
+ A [Textual](https://textual.textualize.io/) terminal widget backed by
4
+ [libghostty-vt](https://libghostty.tip.ghostty.org/), Ghostty's embeddable
5
+ terminal emulation core.
6
+
7
+ The headless emulator and `TerminalView` widget are implemented. The public
8
+ renderer uses libghostty's render-state and cell accessor APIs, keeps Ghostty's
9
+ viewport authoritative, and routes terminal replies and user input through one
10
+ ordered transport queue.
11
+
12
+ ## Why
13
+
14
+ Existing Python terminal emulators force a choice: `pyte` is unmaintained and
15
+ structurally unprepared for modern escape sequences, while newer pure-Python
16
+ parsers are young enough that their own scrollback support is still in progress.
17
+ `libghostty-vt` is neither — it is Ghostty's shipping core, extracted as a
18
+ zero-dependency C library with scrollback, reflow, and grapheme handling built
19
+ in.
20
+
21
+ ## Design principle
22
+
23
+ **The library never owns a process, PTY, or transport.** Bytes in, bytes out.
24
+
25
+ That constraint is what makes it usable by applications that already own their
26
+ process lifecycle — SSH clients with password injection and process-group
27
+ cleanup, for example — and it is why existing batteries-included Textual
28
+ terminal widgets were not an option.
29
+
30
+ Two layers, one native boundary:
31
+
32
+ | Module | Role |
33
+ |---|---|
34
+ | `_native.py` | Loader, ABI verification, union-by-value shims. The only file that knows `pyghostty` exists. |
35
+ | `_render.py` | Private public-ABI render-state extraction and dirty acknowledgement. |
36
+ | `emulator.py` | `Terminal` — headless, no Textual import. Absorbs libghostty API churn. |
37
+ | `widget.py` | `TerminalView` — rendering, input, selection. No C knowledge. |
38
+
39
+ ## Minimal use
40
+
41
+ Install the package from PyPI:
42
+
43
+ ```bash
44
+ pip install ghostty-textual
45
+ ```
46
+
47
+ The distribution name is `ghostty-textual`; the Python import is `ghostty_textual`.
48
+
49
+ ```python
50
+ from ghostty_textual import TerminalView
51
+
52
+ view = TerminalView(
53
+ send=session.send,
54
+ resize_transport=lambda cols, rows: session.resize(rows, cols),
55
+ )
56
+ session.on_output = view.feed
57
+ ```
58
+
59
+ `feed()` parses bytes and queues replies immediately, then coalesces frame
60
+ extraction until the next event-loop turn. Call `view.refresh_frame()` after
61
+ feeding when you need to inspect the rendered frame synchronously. Synchronized
62
+ output still withholds frames until release or timeout; `refresh_frame(force=True)`
63
+ explicitly bypasses that hold.
64
+
65
+ The application remains responsible for process and PTY lifecycle. For
66
+ reconnects, close the old process, `await view.reset_io()`, call
67
+ `view.hard_reset()`, then start the replacement process.
68
+
69
+ ## Design documents
70
+
71
+ - [`docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design-v2.md)
72
+ — current design
73
+ - [`docs/superpowers/specs/sol-5.6-textual-design-review.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/sol-5.6-textual-design-review.md)
74
+ — review that produced it
75
+ - [`docs/superpowers/specs/2026-07-25-ghostty-textual-design.md`](https://github.com/ayder/ghostty-textual/blob/main/docs/superpowers/specs/2026-07-25-ghostty-textual-design.md)
76
+ — superseded v1
77
+
78
+ ## Supported platforms
79
+
80
+ Requires Python 3.12+ and pins `pyghostty==0.1.4`, which bundles libghostty-vt
81
+ built for baseline CPUs. No native compilation is needed to install it.
82
+
83
+ | OS | Architectures | Minimum OS / libc |
84
+ |---|---|---|
85
+ | Linux | `amd64` / `x86_64`, `aarch64` / `arm64` | glibc 2.27 |
86
+ | macOS | `amd64` / `x86_64` (Intel), `arm64` / `aarch64` (Apple Silicon) | macOS 13 |
87
+
88
+ `amd64` and `x86_64` name the same architecture; so do `aarch64` and `arm64`.
89
+ Use a 64-bit OS and Python on ARM devices such as Raspberry Pi 5. Upstream
90
+ does not publish Windows, musl/Alpine Linux, or 32-bit ARM wheels.
91
+
92
+ CI runs the native ABI and functional tests, plus a clean wheel installation
93
+ and terminal smoke test, on Linux and macOS with both CPU architectures using
94
+ Python 3.12. An additional Linux x86_64 job runs the same checks with Python 3.15.
95
+ Python 3.15 requires CFFI 2.1.1 or newer to avoid an upstream callback ABI crash;
96
+ this minimum is enforced by the package dependencies.
97
+
98
+ The bundled native library retains a cell's base codepoint plus at most 64
99
+ additional grapheme codepoints. Further combining codepoints are ignored to
100
+ bound memory and processing costs. This applies across fragmented input too.
101
+ The scheduled ABI drift job tests the latest upstream binding separately from
102
+ the exact dependency used by normal CI and released distributions.
103
+
104
+ ## Develop
105
+
106
+ ```bash
107
+ uv sync --all-extras
108
+ uv run pytest
109
+ uv run ruff check src tests
110
+ ```
111
+
112
+ The full-frame extraction benchmark defaults to a 10 ms p95 budget for a
113
+ 120×40 terminal. GitHub CI enforces 100 ms on Linux and macOS ARM64. Hosted macOS
114
+ Intel records median/p95 and budget violations without failing on timing alone;
115
+ that runner has exceeded 100 ms even on the pre-optimization baseline. Functional,
116
+ memory, ABI, and packaging checks remain mandatory on every platform. Native
117
+ extraction errors still fail the timing step, including on Intel.
118
+
119
+ Set `GHOSTTY_TEXTUAL_FRAME_BUDGET_MS` to override the budget locally. The default
120
+ `GHOSTTY_TEXTUAL_FRAME_BUDGET_MODE=enforce` asserts the p95 budget; `report` records
121
+ timings without asserting the cutoff. CI runs the timing test separately with
122
+ output capture disabled so successful runs also retain measurements in their logs.
123
+
124
+ Additional benchmarks cover styled and linked extraction, row rendering,
125
+ selection, and fragmented input through a mounted widget:
126
+
127
+ ```bash
128
+ uv run python benchmarks/frame_extraction.py
129
+ uv run python benchmarks/widget_rendering.py
130
+ uv run python benchmarks/feed_bursts.py
131
+ ```
132
+
133
+ See [performance measurements and cache design](https://github.com/ayder/ghostty-textual/blob/main/docs/performance.md) for results
134
+ and measurement scope.
135
+
136
+ ## Releasing
137
+
138
+ Update the version in `pyproject.toml` and `uv.lock`, merge the release PR, then
139
+ push a matching tag such as `v0.1.0`. The tag must point to the merged commit that
140
+ contains that version and the release workflow.
141
+
142
+ CI scans the full Git history with Gitleaks, then requires that scan and the
143
+ four-platform test matrix to pass before releasing. It also checks distribution
144
+ metadata and README rendering with `twine check --strict`.
145
+
146
+ CI builds and smoke-tests distributions on all four supported platforms. The
147
+ Linux x86_64 job retains its wheel and source archive as the `python-dist` Actions
148
+ artifact on every run. After all platform jobs pass on a `v*` tag push, the release
149
+ job verifies the tag, package version, filenames, and archive metadata, then
150
+ attaches the tested files to a GitHub Release. It creates a draft when needed and
151
+ publishes only after the uploads succeed; an existing release keeps its notes.
152
+ A separate job then publishes the same tested artifacts to PyPI as
153
+ `ghostty-textual` through Trusted Publishing, with provenance attestations.
154
+
155
+ The wheel is `ghostty_textual-<version>-py3-none-any.whl`: one wheel supports all
156
+ the listed platforms, with the native binary supplied by the pinned `pyghostty`
157
+ dependency. The source archive is `ghostty_textual-<version>.tar.gz`.
158
+
159
+ Retry a failed release job from Actions. Already uploaded assets are downloaded
160
+ and compared before being reused; differing files fail rather than replacing a
161
+ published asset. Only the GitHub release job receives `contents: write`; only the
162
+ PyPI job receives `id-token: write`. The PyPI job downloads the tested artifact
163
+ without checking out or building repository code. No PyPI API token is stored.
164
+
165
+ Before the first PyPI release:
166
+
167
+ 1. Create the GitHub Actions environment `pypi` in `ayder/ghostty-textual`.
168
+ Restrict its deployment tags to `v*`; add required reviewers if desired.
169
+ 2. In the intended owner's [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
170
+ add a pending Trusted Publisher with project name `ghostty-textual`, owner
171
+ `ayder`, repository `ghostty-textual`, workflow `ci.yml`, and environment `pypi`.
172
+ For an existing project, add the publisher in that project's publishing settings.
173
+ 3. Merge the release PR and push `v0.1.0`. PR and branch builds validate packages
174
+ but do not upload to either registry. A failed PyPI job can be rerun after
175
+ fixing publisher configuration. Already published versions cannot be replaced;
176
+ use a new version for subsequent releases.
177
+
178
+ For a local secret scan, install [Gitleaks](https://github.com/gitleaks/gitleaks)
179
+ and run `gitleaks git --redact --no-banner --log-opts=--all .`.
180
+
181
+ ## Licence
182
+
183
+ MIT
@@ -0,0 +1,58 @@
1
+ """Measure feed bursts through a mounted widget, including deferred extraction.
2
+
3
+ The timed region ends after one loop turn. It excludes Textual composition and
4
+ terminal output; use widget_rendering.py to measure row rendering separately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import statistics
12
+ import time
13
+
14
+ from textual.app import App, ComposeResult
15
+
16
+ from ghostty_textual.widget import TerminalView
17
+
18
+
19
+ class BenchmarkApp(App):
20
+ def compose(self) -> ComposeResult:
21
+ self.view = TerminalView(send=self.send)
22
+ yield self.view
23
+
24
+ async def send(self, data: bytes) -> None:
25
+ pass
26
+
27
+
28
+ async def run(samples: int) -> None:
29
+ app = BenchmarkApp()
30
+ async with app.run_test(size=(120, 40)) as pilot:
31
+ for chunk_size in (1, 64, 4096):
32
+ chunks = [b"x" * chunk_size] * (4096 // chunk_size)
33
+ values = []
34
+ for index in range(samples + 5):
35
+ app.view.hard_reset()
36
+ await pilot.pause()
37
+ start = time.perf_counter()
38
+ for chunk in chunks:
39
+ app.view.feed(chunk)
40
+ await asyncio.sleep(0)
41
+ elapsed = (time.perf_counter() - start) * 1000
42
+ if index >= 5:
43
+ values.append(elapsed)
44
+ print(
45
+ f"4KiB widget feed, chunks={chunk_size}: "
46
+ f"median {statistics.median(values):.3f} ms "
47
+ f"p95 {sorted(values)[min(int(samples * 0.95), samples - 1)]:.3f} ms",
48
+ flush=True,
49
+ )
50
+
51
+
52
+ if __name__ == "__main__":
53
+ parser = argparse.ArgumentParser(description=__doc__)
54
+ parser.add_argument("--samples", type=int, default=10)
55
+ args = parser.parse_args()
56
+ if args.samples < 1:
57
+ parser.error("sample count must be positive")
58
+ asyncio.run(run(args.samples))
@@ -0,0 +1,114 @@
1
+ """Extraction and feed benchmarks; run from the repository's Python environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import statistics
7
+ import time
8
+ from collections.abc import Callable
9
+
10
+ from ghostty_textual.emulator import Terminal
11
+
12
+ COLS, ROWS = 120, 40
13
+
14
+
15
+ def screen(workload: str) -> bytes:
16
+ line = {
17
+ "plain": b"x" * COLS,
18
+ "styled": b"\x1b[1;38;2;120;80;160m" + b"x" * COLS + b"\x1b[0m",
19
+ "alternating": b"\x1b[31mx\x1b[32mx" * (COLS // 2) + b"\x1b[0m",
20
+ "linked": b"\x1b]8;;https://example.com\x1b\\" + b"x" * COLS + b"\x1b]8;;\x1b\\",
21
+ }[workload]
22
+ return b"\x1b[?25l" + b"".join(b"\x1b[%d;1H" % (y + 1) + line for y in range(ROWS))
23
+
24
+
25
+ def measure(
26
+ name: str,
27
+ operation: Callable[[], object],
28
+ count: int,
29
+ *,
30
+ prepare: Callable[[], object] | None = None,
31
+ ) -> None:
32
+ values: list[float] = []
33
+ for index in range(count + 5):
34
+ if prepare is not None:
35
+ prepare()
36
+ start = time.perf_counter()
37
+ operation()
38
+ elapsed = (time.perf_counter() - start) * 1000
39
+ if index >= 5:
40
+ values.append(elapsed)
41
+ ordered = sorted(values)
42
+ print(
43
+ f"{name}: median {statistics.median(values):.3f} ms "
44
+ f"p95 {ordered[min(int(count * 0.95), count - 1)]:.3f} ms",
45
+ flush=True,
46
+ )
47
+
48
+
49
+ def main() -> None:
50
+ parser = argparse.ArgumentParser(description=__doc__)
51
+ parser.add_argument("--samples", type=int, default=100)
52
+ parser.add_argument("--fragment-samples", type=int, default=10)
53
+ args = parser.parse_args()
54
+ if min(args.samples, args.fragment_samples) < 1:
55
+ parser.error("sample counts must be positive")
56
+
57
+ for workload in ("plain", "styled", "alternating", "linked"):
58
+ with Terminal(COLS, ROWS) as terminal:
59
+ terminal.feed(screen(workload))
60
+ measure(
61
+ workload + " full extraction", lambda: terminal.snapshot(force=True), args.samples
62
+ )
63
+ with Terminal(COLS, ROWS) as terminal:
64
+ terminal.feed(screen("plain"))
65
+ terminal.snapshot()
66
+ measure("idle snapshot", terminal.snapshot, args.samples)
67
+ measure(
68
+ "single dirty row",
69
+ terminal.snapshot,
70
+ args.samples,
71
+ prepare=lambda: terminal.feed(b"\x1b[1;1Hx"),
72
+ )
73
+ measure(
74
+ "cursor-only snapshot",
75
+ terminal.snapshot,
76
+ args.samples,
77
+ prepare=lambda: terminal.feed(
78
+ b"\x1b[1;1H" if terminal._last_cursor.x else b"\x1b[1;2H"
79
+ ),
80
+ )
81
+ measure(
82
+ "suppressed sync snapshot",
83
+ terminal.snapshot,
84
+ args.samples,
85
+ prepare=lambda: terminal.feed(b"\x1b[?2026l\x1b[?2026hx"),
86
+ )
87
+
88
+ for chunk_size, every_chunk in ((1, True), (64, True), (4096, True), (1, False)):
89
+ with Terminal(COLS, ROWS) as terminal:
90
+ chunks = [b"x" * chunk_size] * (4096 // chunk_size)
91
+
92
+ def run(chunks=chunks, every_chunk=every_chunk) -> None:
93
+ for chunk in chunks:
94
+ terminal.feed(chunk)
95
+ if every_chunk:
96
+ terminal.snapshot()
97
+ if not every_chunk:
98
+ terminal.snapshot()
99
+
100
+ def reset() -> None:
101
+ terminal.hard_reset()
102
+ terminal.snapshot()
103
+
104
+ label = "each chunk" if every_chunk else "once"
105
+ measure(
106
+ f"4KiB feed, chunks={chunk_size}, snapshot {label}",
107
+ run,
108
+ args.fragment_samples,
109
+ prepare=reset,
110
+ )
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,40 @@
1
+ """Measure row rendering separately from native frame extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from frame_extraction import COLS, ROWS, measure, screen
8
+
9
+ from ghostty_textual.emulator import Terminal
10
+ from ghostty_textual.widget import TerminalView
11
+
12
+
13
+ async def send(data: bytes) -> None:
14
+ pass
15
+
16
+
17
+ def main() -> None:
18
+ parser = argparse.ArgumentParser(description=__doc__)
19
+ parser.add_argument("--samples", type=int, default=100)
20
+ args = parser.parse_args()
21
+ if args.samples < 1:
22
+ parser.error("sample count must be positive")
23
+ for workload in ("plain", "styled", "alternating", "linked"):
24
+ with Terminal(COLS, ROWS) as terminal:
25
+ terminal.feed(screen(workload))
26
+ view = TerminalView(send=send, terminal=terminal)
27
+ view._apply_frame(terminal.snapshot(force=True))
28
+
29
+ def render(view=view) -> None:
30
+ for y in range(ROWS):
31
+ view.render_line(y)
32
+
33
+ measure(workload + " render", render, args.samples)
34
+ view._selection_anchor = (0, 0)
35
+ view._selection_end = (COLS - 1, ROWS - 1)
36
+ measure(workload + " selected render", render, args.samples)
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()