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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -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
+ ```
@@ -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()