tern-sdk 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.
- tern_sdk-0.1.0/.gitignore +6 -0
- tern_sdk-0.1.0/PKG-INFO +215 -0
- tern_sdk-0.1.0/README.md +205 -0
- tern_sdk-0.1.0/examples/ask.py +72 -0
- tern_sdk-0.1.0/examples/chat.py +110 -0
- tern_sdk-0.1.0/examples/progress.py +82 -0
- tern_sdk-0.1.0/examples/table.py +52 -0
- tern_sdk-0.1.0/pyproject.toml +33 -0
- tern_sdk-0.1.0/src/tern_sdk/__init__.py +51 -0
- tern_sdk-0.1.0/src/tern_sdk/helpers.py +475 -0
- tern_sdk-0.1.0/src/tern_sdk/input.py +216 -0
- tern_sdk-0.1.0/src/tern_sdk/keys.py +282 -0
- tern_sdk-0.1.0/src/tern_sdk/py.typed +0 -0
- tern_sdk-0.1.0/src/tern_sdk/reconcile.py +267 -0
- tern_sdk-0.1.0/src/tern_sdk/session.py +923 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/__init__.py +174 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/_html.py +134 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/_kinds.py +607 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/_node.py +213 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/_shadow.py +48 -0
- tern_sdk-0.1.0/src/tern_sdk/ui/_types.py +600 -0
- tern_sdk-0.1.0/src/tern_sdk/wire.py +730 -0
- tern_sdk-0.1.0/tests/conftest.py +120 -0
- tern_sdk-0.1.0/tests/test_helpers.py +129 -0
- tern_sdk-0.1.0/tests/test_regressions.py +504 -0
- tern_sdk-0.1.0/tests/test_session.py +305 -0
- tern_sdk-0.1.0/tests/test_ui.py +105 -0
- tern_sdk-0.1.0/tests/test_wire.py +95 -0
- tern_sdk-0.1.0/uv.lock +79 -0
tern_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tern-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Talk to Tern over the Tern Surface Protocol: typed nodes, diffing, flow control, events, and a plain-text fallback.
|
|
5
|
+
Project-URL: Repository, https://github.com/stencil-hq/tern-sdk
|
|
6
|
+
Author: Stencil Labs, Inc.
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# tern-sdk (Python)
|
|
12
|
+
|
|
13
|
+
The Python SDK for the [Tern Surface Protocol](https://docs.stencil.so/tern/protocol/index.html):
|
|
14
|
+
a program running in a terminal pane describes its UI as a tree of nodes and
|
|
15
|
+
Tern draws it natively. Outside Tern the same program falls back to plain
|
|
16
|
+
text.
|
|
17
|
+
|
|
18
|
+
Python 3.11 or newer, standard library only.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
uv add tern-sdk
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The distribution is `tern-sdk`; the import is `tern_sdk`.
|
|
27
|
+
|
|
28
|
+
## Quick start
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import tern_sdk
|
|
32
|
+
from tern_sdk import ui
|
|
33
|
+
|
|
34
|
+
# Static output that stays in the scrollback (plain text outside Tern).
|
|
35
|
+
tern_sdk.show(ui.card(ui.md("All **42** tests pass."), head="cargo test", status="done"))
|
|
36
|
+
|
|
37
|
+
# One question, its answer returned.
|
|
38
|
+
form = ui.html.form(
|
|
39
|
+
ui.html.p("Which size?"),
|
|
40
|
+
ui.html.label(ui.html.input(type="radio", name="size", value="s"), "Small"),
|
|
41
|
+
ui.html.label(ui.html.input(type="radio", name="size", value="l"), "Large"),
|
|
42
|
+
ui.html.button("Create", actions={"click": "submit"}),
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
answer = tern_sdk.ask(form)
|
|
46
|
+
print(answer.values["size"] if answer else "cancelled")
|
|
47
|
+
except tern_sdk.Unsupported:
|
|
48
|
+
size = input("Which size? ")
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A live surface:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
session = tern_sdk.connect(app="build")
|
|
55
|
+
if session is None:
|
|
56
|
+
print("building...") # no TSP: plain output
|
|
57
|
+
else:
|
|
58
|
+
with session, session.open(mode="flow") as surface:
|
|
59
|
+
surface.render(ui.card(ui.spinner("Compiling"), head="Build", status="running", key="b"))
|
|
60
|
+
for item in session.input(timeout=0.1):
|
|
61
|
+
...
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`connect()` returns `None` when TSP isn't available (no exception for that
|
|
65
|
+
case); a `Session` and a `Surface` are context managers that close on exit.
|
|
66
|
+
|
|
67
|
+
## Layers
|
|
68
|
+
|
|
69
|
+
Each layer is usable without the ones above it.
|
|
70
|
+
|
|
71
|
+
| Module | What it provides |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `tern_sdk.wire` | Constants (`VERSION`, `APC_LIMIT`, `CREDITS`, `KINDS`, `TEXT_KINDS`, features), program -> terminal messages (`hello`, `blobs_query`, `open_surface`, `frame`, `blob`, `palette`, `stylesheet`, `close_surface`, `Ops.*` for the twelve frame ops), the encoder (`encode`, `chunks`, `ChunkIds`) and typed replies and events (`decode_reply`, `decode_event`; every one keeps its `raw` object) |
|
|
74
|
+
| `tern_sdk.input` | `InputParser`: feed pty input, get key bytes, `Reply`, `Event` and `DA1` items; `flush()` releases undecided prefixes |
|
|
75
|
+
| `tern_sdk.keys` | `KeyDecoder`: key bytes to `Key(name, text, ctrl, alt, shift, meta)`, legacy xterm, kitty `CSI u` and bracketed paste |
|
|
76
|
+
| `tern_sdk.ui` | Builders for every kind but `block`, `span`, `el` builders per tag under `ui.html`, and `node(kind, props, children)` |
|
|
77
|
+
| `tern_sdk.reconcile` | `View.build(view)` and `View.ops(next, surface)`: Tern's own diff with derived ids |
|
|
78
|
+
| `tern_sdk.session` | `connect`, `start`, `Session`, `Surface`, `Capabilities` |
|
|
79
|
+
| `tern_sdk.helpers` | `show`, `ask`, `plain` (also at the top level) |
|
|
80
|
+
|
|
81
|
+
### Nodes
|
|
82
|
+
|
|
83
|
+
Children are positional and props keyword-only. Text is a string or styled
|
|
84
|
+
spans (`ui.span("3 failed", "error")`). Snake-case keywords map to the
|
|
85
|
+
wire's camelCase names (`target_kind` is `targetKind`, `max_lines` is
|
|
86
|
+
`maxLines`); `el`'s `class` is `class_`. Structured props are `TypedDict`s
|
|
87
|
+
whose keys are the wire's names. Enumerations are `Literal`s; `props={...}`
|
|
88
|
+
sets any prop untyped and `ui.node(kind, props, children)` builds any kind.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
ui.col(
|
|
92
|
+
ui.card(ui.kv([("service", "api"), ("region", "eu-west-1")]), head="Deploy", status="running", key="d"),
|
|
93
|
+
ui.row(ui.badge("v2.3.1", tone="accent"), ui.icon("check", tone="success"), ui.kbd("cmd", "k")),
|
|
94
|
+
ui.html.table(ui.html.tr(ui.html.td("Cargo.toml"), ui.html.td("2.1K", class_="size"))),
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The common props (`key`, `role`, `tone`, `hidden`, `mark`, `actions`,
|
|
99
|
+
`title`, `aria`, `href`, `grow`, `shrink`, `basis`, `min`, `max`) are
|
|
100
|
+
keywords on every builder. In `ui.html.*`, a leading string child is the
|
|
101
|
+
element's `text`; later string children become `span` elements.
|
|
102
|
+
|
|
103
|
+
### Handlers
|
|
104
|
+
|
|
105
|
+
Any builder takes handlers; each receives the typed event:
|
|
106
|
+
|
|
107
|
+
| Keyword | Runs on |
|
|
108
|
+
| --- | --- |
|
|
109
|
+
| `on_click`, `on_dblclick` | the click / double-click action (sets `actions.click` to `"click"` unless the node names its own) |
|
|
110
|
+
| `on_menu={"rerun": fn}` | context-menu picks (adds the names to `actions.menu`) |
|
|
111
|
+
| `on_action={"name": fn}` | `action` events with that `act` |
|
|
112
|
+
| `on_toggle`, `on_select`, `on_activate`, `on_change`, `on_focus`, `on_edit`, `on_undo`, `on_send` | those events |
|
|
113
|
+
|
|
114
|
+
Events are routed by `sf` and `id` to the node with that id in the view last
|
|
115
|
+
rendered (for a list's `select`/`activate`, the list). Handlers run inside
|
|
116
|
+
`session.poll()` / `session.input()`; handled events are consumed and every
|
|
117
|
+
other event reaches the loop. Named gesture actions such as `sort=name`
|
|
118
|
+
match the event's `act="sort"` and `value="name"`. `ask` returns its first
|
|
119
|
+
submit action even when a node handler handles it; that handler runs first.
|
|
120
|
+
|
|
121
|
+
Kind-specific props replace colliding common props: `tool`/`picker` titles
|
|
122
|
+
accept spans, `prefs` owns its title, `list` owns `max`, and `picker` owns
|
|
123
|
+
its action list. `AgentStatus` is separate from card/tool `Status`.
|
|
124
|
+
Table meter cells use `{"meter": {"value": 0.5}}` (or `parts` inside
|
|
125
|
+
`meter`), typed as `MeterCell` containing `MeterCellProps`; plain tables
|
|
126
|
+
render those meters too.
|
|
127
|
+
|
|
128
|
+
### Session
|
|
129
|
+
|
|
130
|
+
- `connect(app=None, *, version=None, features=(), timeout=1.0, paste=True, kitty=True)`
|
|
131
|
+
checks the environment, switches the tty to raw mode, sends `hello` + DA1
|
|
132
|
+
and returns a `Session`, or `None`.
|
|
133
|
+
- `start(input, output, ...)` runs the handshake on given file descriptors
|
|
134
|
+
or binary streams without the environment checks (tests drive it over
|
|
135
|
+
`os.pipe()`).
|
|
136
|
+
- `session.open(id=None, *, mode="inline", title=None, role=None, listen=True, adopt=None, keep=True)`
|
|
137
|
+
opens a surface (`s1`, `s2`, ...).
|
|
138
|
+
- `surface.render(view)` or `surface.render(main=..., dock=..., layer=...)`
|
|
139
|
+
sends only the difference. A node given for a region is that region's
|
|
140
|
+
root, whose own kind and props Tern doesn't draw (only its children); a
|
|
141
|
+
list region is wrapped in a `col`; a bare node or list passed as the whole
|
|
142
|
+
view becomes `main`'s children. Also `stylesheet(name, css)`, `palette(dark=, light=, name=)`,
|
|
143
|
+
`focus`, `reveal`, `scroll`, `settle`, `suspend`, `resume`, raw
|
|
144
|
+
`send(ops)` and `close(keep=None)`.
|
|
145
|
+
- Flow control: at most `credits` frames in flight; a render while blocked
|
|
146
|
+
stores the view, and the next `ack` sends one frame with the difference
|
|
147
|
+
from the last view sent, then queued ops. `close` sends what is still
|
|
148
|
+
pending as a last frame even without credit, so a kept surface shows its
|
|
149
|
+
final state. A failed frame write raises without advancing the sent view,
|
|
150
|
+
sequence, or queued ops, including a flush triggered by an ack. Re-rendering
|
|
151
|
+
a mutable node detects changes to nested props.
|
|
152
|
+
- `adopt=True` reuses a closed surface's last-sent `main` and frame sequence
|
|
153
|
+
in this session (not its former `dock`/`layer`). An unknown adopted id
|
|
154
|
+
starts empty and deletes `main` before its first frame's additions.
|
|
155
|
+
- Input: `for item in session.input(timeout=None):` yields `Key`s and
|
|
156
|
+
unhandled `Event`s and ends at end of input, on close or after `timeout`
|
|
157
|
+
seconds idle; `session.poll(timeout)` returns one item or `None`.
|
|
158
|
+
Even `poll(0)` reads available input, including acks. Iterating buffered
|
|
159
|
+
keys also dispatches available input, so acks promptly flush pending frames
|
|
160
|
+
rather than waiting for the key queue to empty. Held prefixes flush
|
|
161
|
+
only after 30 ms without input, not when a shorter poll expires. Key
|
|
162
|
+
decoder flush drops incomplete sequences except a lone Escape; neither
|
|
163
|
+
parser flushes a paste in progress.
|
|
164
|
+
- `session.blob(data, mime)` sends a blob once and returns its id;
|
|
165
|
+
`session.blobs(ids)` asks which Tern holds.
|
|
166
|
+
- `session.close()` closes open surfaces, undoes bracketed paste and the
|
|
167
|
+
kitty flag, drains input for 50 ms and restores the tty. It also runs from
|
|
168
|
+
the `with` block, at process exit (`atexit`) and on SIGINT / SIGTERM / SIGHUP
|
|
169
|
+
when started on Python's main thread. Cleanup is armed before raw mode
|
|
170
|
+
and the handshake; only one session may own tty state at a time.
|
|
171
|
+
Close attempts `x` even after a failed final frame, resets modes and restores
|
|
172
|
+
the tty despite write errors, then raises the first error. The drain records
|
|
173
|
+
late replies without invoking handlers. Windows console waits discard
|
|
174
|
+
non-character records rather than blocking on a character read.
|
|
175
|
+
|
|
176
|
+
### Helpers
|
|
177
|
+
|
|
178
|
+
- `show(view, *, css=None, fallback=None, session=None)`: static output in a
|
|
179
|
+
`flow` surface opened with `listen: false`. It is named `show`, not
|
|
180
|
+
`print`, so it never shadows the builtin. Without TSP it prints
|
|
181
|
+
`plain(view)` or `fallback`.
|
|
182
|
+
- `ask(view, *, css=None, submit="submit", session=None) -> Answer | None`:
|
|
183
|
+
returns the first `action` whose `act` is `submit` as
|
|
184
|
+
`Answer(id, act, values)`, or `None` on Escape, Ctrl+C or Ctrl+D. Without
|
|
185
|
+
TSP it raises `tern_sdk.Unsupported`.
|
|
186
|
+
- `plain(view, cols=80) -> str`: readable plain text without escapes.
|
|
187
|
+
|
|
188
|
+
## Environment
|
|
189
|
+
|
|
190
|
+
| Variable | Effect |
|
|
191
|
+
| --- | --- |
|
|
192
|
+
| `TERN_TSP=0` | Never speak TSP |
|
|
193
|
+
| `TERN_TSP_RECORD=<file>` | Append every TSP message as JSONL (`surface-play` replays it) |
|
|
194
|
+
| `TMUX`, `STY`, `ZELLIJ` | Inside a multiplexer: no TSP |
|
|
195
|
+
|
|
196
|
+
## Examples
|
|
197
|
+
|
|
198
|
+
The `ask` example reads the selected size only from the submit action's form
|
|
199
|
+
values, without tracking radio changes. Outside Tern it asks on the command line.
|
|
200
|
+
|
|
201
|
+
```sh
|
|
202
|
+
uv run examples/table.py # the current directory as a table
|
|
203
|
+
uv run examples/ask.py # "Which size?"
|
|
204
|
+
uv run examples/progress.py # a running card, streamed log, progress bar
|
|
205
|
+
uv run examples/chat.py # transcript + editor; Enter sends, Escape quits
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## Development
|
|
209
|
+
|
|
210
|
+
```sh
|
|
211
|
+
uv run pytest # wire, UI, session, helpers
|
|
212
|
+
uvx mypy --strict src examples
|
|
213
|
+
uvx ruff check src tests examples
|
|
214
|
+
uvx ruff format --check src tests examples
|
|
215
|
+
```
|
tern_sdk-0.1.0/README.md
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# tern-sdk (Python)
|
|
2
|
+
|
|
3
|
+
The Python SDK for the [Tern Surface Protocol](https://docs.stencil.so/tern/protocol/index.html):
|
|
4
|
+
a program running in a terminal pane describes its UI as a tree of nodes and
|
|
5
|
+
Tern draws it natively. Outside Tern the same program falls back to plain
|
|
6
|
+
text.
|
|
7
|
+
|
|
8
|
+
Python 3.11 or newer, standard library only.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
uv add tern-sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The distribution is `tern-sdk`; the import is `tern_sdk`.
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import tern_sdk
|
|
22
|
+
from tern_sdk import ui
|
|
23
|
+
|
|
24
|
+
# Static output that stays in the scrollback (plain text outside Tern).
|
|
25
|
+
tern_sdk.show(ui.card(ui.md("All **42** tests pass."), head="cargo test", status="done"))
|
|
26
|
+
|
|
27
|
+
# One question, its answer returned.
|
|
28
|
+
form = ui.html.form(
|
|
29
|
+
ui.html.p("Which size?"),
|
|
30
|
+
ui.html.label(ui.html.input(type="radio", name="size", value="s"), "Small"),
|
|
31
|
+
ui.html.label(ui.html.input(type="radio", name="size", value="l"), "Large"),
|
|
32
|
+
ui.html.button("Create", actions={"click": "submit"}),
|
|
33
|
+
)
|
|
34
|
+
try:
|
|
35
|
+
answer = tern_sdk.ask(form)
|
|
36
|
+
print(answer.values["size"] if answer else "cancelled")
|
|
37
|
+
except tern_sdk.Unsupported:
|
|
38
|
+
size = input("Which size? ")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A live surface:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
session = tern_sdk.connect(app="build")
|
|
45
|
+
if session is None:
|
|
46
|
+
print("building...") # no TSP: plain output
|
|
47
|
+
else:
|
|
48
|
+
with session, session.open(mode="flow") as surface:
|
|
49
|
+
surface.render(ui.card(ui.spinner("Compiling"), head="Build", status="running", key="b"))
|
|
50
|
+
for item in session.input(timeout=0.1):
|
|
51
|
+
...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`connect()` returns `None` when TSP isn't available (no exception for that
|
|
55
|
+
case); a `Session` and a `Surface` are context managers that close on exit.
|
|
56
|
+
|
|
57
|
+
## Layers
|
|
58
|
+
|
|
59
|
+
Each layer is usable without the ones above it.
|
|
60
|
+
|
|
61
|
+
| Module | What it provides |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `tern_sdk.wire` | Constants (`VERSION`, `APC_LIMIT`, `CREDITS`, `KINDS`, `TEXT_KINDS`, features), program -> terminal messages (`hello`, `blobs_query`, `open_surface`, `frame`, `blob`, `palette`, `stylesheet`, `close_surface`, `Ops.*` for the twelve frame ops), the encoder (`encode`, `chunks`, `ChunkIds`) and typed replies and events (`decode_reply`, `decode_event`; every one keeps its `raw` object) |
|
|
64
|
+
| `tern_sdk.input` | `InputParser`: feed pty input, get key bytes, `Reply`, `Event` and `DA1` items; `flush()` releases undecided prefixes |
|
|
65
|
+
| `tern_sdk.keys` | `KeyDecoder`: key bytes to `Key(name, text, ctrl, alt, shift, meta)`, legacy xterm, kitty `CSI u` and bracketed paste |
|
|
66
|
+
| `tern_sdk.ui` | Builders for every kind but `block`, `span`, `el` builders per tag under `ui.html`, and `node(kind, props, children)` |
|
|
67
|
+
| `tern_sdk.reconcile` | `View.build(view)` and `View.ops(next, surface)`: Tern's own diff with derived ids |
|
|
68
|
+
| `tern_sdk.session` | `connect`, `start`, `Session`, `Surface`, `Capabilities` |
|
|
69
|
+
| `tern_sdk.helpers` | `show`, `ask`, `plain` (also at the top level) |
|
|
70
|
+
|
|
71
|
+
### Nodes
|
|
72
|
+
|
|
73
|
+
Children are positional and props keyword-only. Text is a string or styled
|
|
74
|
+
spans (`ui.span("3 failed", "error")`). Snake-case keywords map to the
|
|
75
|
+
wire's camelCase names (`target_kind` is `targetKind`, `max_lines` is
|
|
76
|
+
`maxLines`); `el`'s `class` is `class_`. Structured props are `TypedDict`s
|
|
77
|
+
whose keys are the wire's names. Enumerations are `Literal`s; `props={...}`
|
|
78
|
+
sets any prop untyped and `ui.node(kind, props, children)` builds any kind.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
ui.col(
|
|
82
|
+
ui.card(ui.kv([("service", "api"), ("region", "eu-west-1")]), head="Deploy", status="running", key="d"),
|
|
83
|
+
ui.row(ui.badge("v2.3.1", tone="accent"), ui.icon("check", tone="success"), ui.kbd("cmd", "k")),
|
|
84
|
+
ui.html.table(ui.html.tr(ui.html.td("Cargo.toml"), ui.html.td("2.1K", class_="size"))),
|
|
85
|
+
)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
The common props (`key`, `role`, `tone`, `hidden`, `mark`, `actions`,
|
|
89
|
+
`title`, `aria`, `href`, `grow`, `shrink`, `basis`, `min`, `max`) are
|
|
90
|
+
keywords on every builder. In `ui.html.*`, a leading string child is the
|
|
91
|
+
element's `text`; later string children become `span` elements.
|
|
92
|
+
|
|
93
|
+
### Handlers
|
|
94
|
+
|
|
95
|
+
Any builder takes handlers; each receives the typed event:
|
|
96
|
+
|
|
97
|
+
| Keyword | Runs on |
|
|
98
|
+
| --- | --- |
|
|
99
|
+
| `on_click`, `on_dblclick` | the click / double-click action (sets `actions.click` to `"click"` unless the node names its own) |
|
|
100
|
+
| `on_menu={"rerun": fn}` | context-menu picks (adds the names to `actions.menu`) |
|
|
101
|
+
| `on_action={"name": fn}` | `action` events with that `act` |
|
|
102
|
+
| `on_toggle`, `on_select`, `on_activate`, `on_change`, `on_focus`, `on_edit`, `on_undo`, `on_send` | those events |
|
|
103
|
+
|
|
104
|
+
Events are routed by `sf` and `id` to the node with that id in the view last
|
|
105
|
+
rendered (for a list's `select`/`activate`, the list). Handlers run inside
|
|
106
|
+
`session.poll()` / `session.input()`; handled events are consumed and every
|
|
107
|
+
other event reaches the loop. Named gesture actions such as `sort=name`
|
|
108
|
+
match the event's `act="sort"` and `value="name"`. `ask` returns its first
|
|
109
|
+
submit action even when a node handler handles it; that handler runs first.
|
|
110
|
+
|
|
111
|
+
Kind-specific props replace colliding common props: `tool`/`picker` titles
|
|
112
|
+
accept spans, `prefs` owns its title, `list` owns `max`, and `picker` owns
|
|
113
|
+
its action list. `AgentStatus` is separate from card/tool `Status`.
|
|
114
|
+
Table meter cells use `{"meter": {"value": 0.5}}` (or `parts` inside
|
|
115
|
+
`meter`), typed as `MeterCell` containing `MeterCellProps`; plain tables
|
|
116
|
+
render those meters too.
|
|
117
|
+
|
|
118
|
+
### Session
|
|
119
|
+
|
|
120
|
+
- `connect(app=None, *, version=None, features=(), timeout=1.0, paste=True, kitty=True)`
|
|
121
|
+
checks the environment, switches the tty to raw mode, sends `hello` + DA1
|
|
122
|
+
and returns a `Session`, or `None`.
|
|
123
|
+
- `start(input, output, ...)` runs the handshake on given file descriptors
|
|
124
|
+
or binary streams without the environment checks (tests drive it over
|
|
125
|
+
`os.pipe()`).
|
|
126
|
+
- `session.open(id=None, *, mode="inline", title=None, role=None, listen=True, adopt=None, keep=True)`
|
|
127
|
+
opens a surface (`s1`, `s2`, ...).
|
|
128
|
+
- `surface.render(view)` or `surface.render(main=..., dock=..., layer=...)`
|
|
129
|
+
sends only the difference. A node given for a region is that region's
|
|
130
|
+
root, whose own kind and props Tern doesn't draw (only its children); a
|
|
131
|
+
list region is wrapped in a `col`; a bare node or list passed as the whole
|
|
132
|
+
view becomes `main`'s children. Also `stylesheet(name, css)`, `palette(dark=, light=, name=)`,
|
|
133
|
+
`focus`, `reveal`, `scroll`, `settle`, `suspend`, `resume`, raw
|
|
134
|
+
`send(ops)` and `close(keep=None)`.
|
|
135
|
+
- Flow control: at most `credits` frames in flight; a render while blocked
|
|
136
|
+
stores the view, and the next `ack` sends one frame with the difference
|
|
137
|
+
from the last view sent, then queued ops. `close` sends what is still
|
|
138
|
+
pending as a last frame even without credit, so a kept surface shows its
|
|
139
|
+
final state. A failed frame write raises without advancing the sent view,
|
|
140
|
+
sequence, or queued ops, including a flush triggered by an ack. Re-rendering
|
|
141
|
+
a mutable node detects changes to nested props.
|
|
142
|
+
- `adopt=True` reuses a closed surface's last-sent `main` and frame sequence
|
|
143
|
+
in this session (not its former `dock`/`layer`). An unknown adopted id
|
|
144
|
+
starts empty and deletes `main` before its first frame's additions.
|
|
145
|
+
- Input: `for item in session.input(timeout=None):` yields `Key`s and
|
|
146
|
+
unhandled `Event`s and ends at end of input, on close or after `timeout`
|
|
147
|
+
seconds idle; `session.poll(timeout)` returns one item or `None`.
|
|
148
|
+
Even `poll(0)` reads available input, including acks. Iterating buffered
|
|
149
|
+
keys also dispatches available input, so acks promptly flush pending frames
|
|
150
|
+
rather than waiting for the key queue to empty. Held prefixes flush
|
|
151
|
+
only after 30 ms without input, not when a shorter poll expires. Key
|
|
152
|
+
decoder flush drops incomplete sequences except a lone Escape; neither
|
|
153
|
+
parser flushes a paste in progress.
|
|
154
|
+
- `session.blob(data, mime)` sends a blob once and returns its id;
|
|
155
|
+
`session.blobs(ids)` asks which Tern holds.
|
|
156
|
+
- `session.close()` closes open surfaces, undoes bracketed paste and the
|
|
157
|
+
kitty flag, drains input for 50 ms and restores the tty. It also runs from
|
|
158
|
+
the `with` block, at process exit (`atexit`) and on SIGINT / SIGTERM / SIGHUP
|
|
159
|
+
when started on Python's main thread. Cleanup is armed before raw mode
|
|
160
|
+
and the handshake; only one session may own tty state at a time.
|
|
161
|
+
Close attempts `x` even after a failed final frame, resets modes and restores
|
|
162
|
+
the tty despite write errors, then raises the first error. The drain records
|
|
163
|
+
late replies without invoking handlers. Windows console waits discard
|
|
164
|
+
non-character records rather than blocking on a character read.
|
|
165
|
+
|
|
166
|
+
### Helpers
|
|
167
|
+
|
|
168
|
+
- `show(view, *, css=None, fallback=None, session=None)`: static output in a
|
|
169
|
+
`flow` surface opened with `listen: false`. It is named `show`, not
|
|
170
|
+
`print`, so it never shadows the builtin. Without TSP it prints
|
|
171
|
+
`plain(view)` or `fallback`.
|
|
172
|
+
- `ask(view, *, css=None, submit="submit", session=None) -> Answer | None`:
|
|
173
|
+
returns the first `action` whose `act` is `submit` as
|
|
174
|
+
`Answer(id, act, values)`, or `None` on Escape, Ctrl+C or Ctrl+D. Without
|
|
175
|
+
TSP it raises `tern_sdk.Unsupported`.
|
|
176
|
+
- `plain(view, cols=80) -> str`: readable plain text without escapes.
|
|
177
|
+
|
|
178
|
+
## Environment
|
|
179
|
+
|
|
180
|
+
| Variable | Effect |
|
|
181
|
+
| --- | --- |
|
|
182
|
+
| `TERN_TSP=0` | Never speak TSP |
|
|
183
|
+
| `TERN_TSP_RECORD=<file>` | Append every TSP message as JSONL (`surface-play` replays it) |
|
|
184
|
+
| `TMUX`, `STY`, `ZELLIJ` | Inside a multiplexer: no TSP |
|
|
185
|
+
|
|
186
|
+
## Examples
|
|
187
|
+
|
|
188
|
+
The `ask` example reads the selected size only from the submit action's form
|
|
189
|
+
values, without tracking radio changes. Outside Tern it asks on the command line.
|
|
190
|
+
|
|
191
|
+
```sh
|
|
192
|
+
uv run examples/table.py # the current directory as a table
|
|
193
|
+
uv run examples/ask.py # "Which size?"
|
|
194
|
+
uv run examples/progress.py # a running card, streamed log, progress bar
|
|
195
|
+
uv run examples/chat.py # transcript + editor; Enter sends, Escape quits
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
## Development
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
uv run pytest # wire, UI, session, helpers
|
|
202
|
+
uvx mypy --strict src examples
|
|
203
|
+
uvx ruff check src tests examples
|
|
204
|
+
uvx ruff format --check src tests examples
|
|
205
|
+
```
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""The "Which size?" form from the protocol book; prints the answer.
|
|
2
|
+
|
|
3
|
+
Inside Tern the form is drawn natively and stays in the scrollback; anywhere
|
|
4
|
+
else the program asks on its own."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import tern_sdk
|
|
11
|
+
from tern_sdk import ui
|
|
12
|
+
|
|
13
|
+
CSS = """
|
|
14
|
+
.ask { display: flex; flex-direction: column; gap: 8px; max-width: 460px;
|
|
15
|
+
padding: 10px 12px; border-radius: 8px; background: var(--card);
|
|
16
|
+
box-shadow: inset 0 0 0 1px var(--l2) }
|
|
17
|
+
.ask p { margin: 0 }
|
|
18
|
+
.ask .sizes { display: flex; gap: 14px }
|
|
19
|
+
.ask :checked+span { color: var(--accent) }
|
|
20
|
+
.ask button { align-self: flex-start; padding: 3px 12px; border-radius: 6px;
|
|
21
|
+
background: var(--accent-fill); color: #fff }
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
SIZES = (("s", "Small"), ("m", "Medium"), ("l", "Large"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def form() -> ui.Node:
|
|
28
|
+
"""The form: a question, three radios and a submit button."""
|
|
29
|
+
|
|
30
|
+
return ui.html.form(
|
|
31
|
+
ui.html.p("Which size?", key="q"),
|
|
32
|
+
ui.html.div(
|
|
33
|
+
*(
|
|
34
|
+
ui.html.label(
|
|
35
|
+
ui.html.input(type="radio", name="size", value=value, key="r"),
|
|
36
|
+
label,
|
|
37
|
+
key=value,
|
|
38
|
+
)
|
|
39
|
+
for value, label in SIZES
|
|
40
|
+
),
|
|
41
|
+
class_="sizes",
|
|
42
|
+
key="sizes",
|
|
43
|
+
),
|
|
44
|
+
ui.html.button("Create", actions={"click": "submit"}, key="go"),
|
|
45
|
+
class_="ask",
|
|
46
|
+
key="ask",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def main() -> int:
|
|
51
|
+
try:
|
|
52
|
+
answer = tern_sdk.ask(form(), css=CSS)
|
|
53
|
+
except tern_sdk.Unsupported:
|
|
54
|
+
try:
|
|
55
|
+
size = input("Which size? [s/m/l] ").strip().lower()[:1]
|
|
56
|
+
except EOFError:
|
|
57
|
+
size = ""
|
|
58
|
+
if size not in dict(SIZES):
|
|
59
|
+
print("cancelled")
|
|
60
|
+
return 1
|
|
61
|
+
print(f"size: {dict(SIZES)[size]}")
|
|
62
|
+
return 0
|
|
63
|
+
if answer is None:
|
|
64
|
+
print("cancelled")
|
|
65
|
+
return 1
|
|
66
|
+
choice = answer.values.get("size")
|
|
67
|
+
print(f"size: {dict(SIZES)[choice]}" if isinstance(choice, str) and choice in dict(SIZES) else "no size picked")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""An `inline` surface: a transcript in `main` and an `editor` in `dock` that
|
|
2
|
+
the program edits from keys. Enter sends, Escape quits.
|
|
3
|
+
|
|
4
|
+
Outside Tern it is a line prompt that echoes what you send."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import tern_sdk
|
|
9
|
+
from tern_sdk import FocusEvent, Key, ui
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Draft:
|
|
13
|
+
"""The editor's text and caret, edited from keys."""
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
self.text = ""
|
|
17
|
+
self.cursor = 0 # in code points
|
|
18
|
+
|
|
19
|
+
def utf16_cursor(self) -> int:
|
|
20
|
+
"""The caret in UTF-16 units, as the `cursor` prop counts it."""
|
|
21
|
+
return len(self.text[: self.cursor].encode("utf-16-le")) // 2
|
|
22
|
+
|
|
23
|
+
def insert(self, s: str) -> None:
|
|
24
|
+
self.text = self.text[: self.cursor] + s + self.text[self.cursor :]
|
|
25
|
+
self.cursor += len(s)
|
|
26
|
+
|
|
27
|
+
def key(self, key: Key) -> None:
|
|
28
|
+
"""Applies an editing key."""
|
|
29
|
+
if key.name == "paste" and key.text:
|
|
30
|
+
self.insert(key.text)
|
|
31
|
+
elif key.name == "backspace" and self.cursor > 0:
|
|
32
|
+
self.text = self.text[: self.cursor - 1] + self.text[self.cursor :]
|
|
33
|
+
self.cursor -= 1
|
|
34
|
+
elif key.name == "delete":
|
|
35
|
+
self.text = self.text[: self.cursor] + self.text[self.cursor + 1 :]
|
|
36
|
+
elif key.name == "left" or (key.ctrl and key.name == "b"):
|
|
37
|
+
self.cursor = max(self.cursor - 1, 0)
|
|
38
|
+
elif key.name == "right" or (key.ctrl and key.name == "f"):
|
|
39
|
+
self.cursor = min(self.cursor + 1, len(self.text))
|
|
40
|
+
elif key.name == "home" or (key.ctrl and key.name == "a"):
|
|
41
|
+
self.cursor = 0
|
|
42
|
+
elif key.name == "end" or (key.ctrl and key.name == "e"):
|
|
43
|
+
self.cursor = len(self.text)
|
|
44
|
+
elif key.ctrl and key.name == "u":
|
|
45
|
+
self.text, self.cursor = self.text[self.cursor :], 0
|
|
46
|
+
elif key.text is not None and not (key.ctrl or key.alt or key.meta):
|
|
47
|
+
self.insert(key.text)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def view(messages: list[str], draft: Draft) -> dict[str, ui.Node]:
|
|
51
|
+
"""The transcript and the composer."""
|
|
52
|
+
transcript = [ui.card(ui.md(text), head="You", tone="user", key=f"m{n}") for n, text in enumerate(messages)] or [
|
|
53
|
+
ui.md("Type a message and press **Enter**. **Escape** quits.", key="hint")
|
|
54
|
+
]
|
|
55
|
+
return {
|
|
56
|
+
"main": ui.col(*transcript, gap="sm"),
|
|
57
|
+
"dock": ui.col(
|
|
58
|
+
ui.editor(
|
|
59
|
+
draft.text,
|
|
60
|
+
cursor=draft.utf16_cursor(),
|
|
61
|
+
placeholder="Message",
|
|
62
|
+
prompt=[ui.span("> ", "accent")],
|
|
63
|
+
key="ed",
|
|
64
|
+
),
|
|
65
|
+
ui.status(ui.seg("chat", icon="message"), ui.seg(f"{len(messages)} sent", side="right"), key="bar"),
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def plain_chat() -> None:
|
|
71
|
+
while True:
|
|
72
|
+
try:
|
|
73
|
+
line = input("> ")
|
|
74
|
+
except EOFError:
|
|
75
|
+
print()
|
|
76
|
+
break
|
|
77
|
+
print(f"you: {line}")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
session = tern_sdk.connect(app="chat")
|
|
82
|
+
if session is None:
|
|
83
|
+
plain_chat()
|
|
84
|
+
return
|
|
85
|
+
messages: list[str] = []
|
|
86
|
+
draft = Draft()
|
|
87
|
+
with session, session.open(mode="inline", title="chat") as surface:
|
|
88
|
+
surface.render(view(messages, draft))
|
|
89
|
+
surface.focus("dock.ed")
|
|
90
|
+
for item in session.input():
|
|
91
|
+
if isinstance(item, FocusEvent):
|
|
92
|
+
surface.focus("dock.ed")
|
|
93
|
+
continue
|
|
94
|
+
if not isinstance(item, Key):
|
|
95
|
+
continue
|
|
96
|
+
if item.name == "escape" or (item.ctrl and item.name in ("c", "d")):
|
|
97
|
+
break
|
|
98
|
+
if item.name == "enter" and not (item.shift or item.alt):
|
|
99
|
+
if draft.text.strip():
|
|
100
|
+
messages.append(draft.text)
|
|
101
|
+
draft = Draft()
|
|
102
|
+
elif item.name == "enter":
|
|
103
|
+
draft.insert("\n")
|
|
104
|
+
else:
|
|
105
|
+
draft.key(item)
|
|
106
|
+
surface.render(view(messages, draft))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|