tern-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tern_sdk/__init__.py ADDED
@@ -0,0 +1,51 @@
1
+ """Talk to Tern over the Tern Surface Protocol (TSP).
2
+
3
+ Six layers, each usable without the ones above it: `wire` (messages,
4
+ framing, chunking), `input` and `keys` (the input parser and key decoder),
5
+ `ui` (typed node builders and handlers), `reconcile` (views diffed into
6
+ frame ops), `session` (handshake, surfaces, flow control, events) and
7
+ `helpers` (`show`, `ask`, `plain`)."""
8
+
9
+ from . import input, keys, reconcile, session, ui, wire
10
+ from .helpers import Answer, Unsupported, ask, plain, show
11
+ from .input import DA1, Da1, InputParser
12
+ from .keys import Key, KeyDecoder
13
+ from .reconcile import AnyView, ReconcileError, View
14
+ from .session import Capabilities, InputItem, Session, Surface, available, connect, start
15
+ from .ui import Node, span
16
+ from .wire import (
17
+ AckEvent,
18
+ ActionEvent,
19
+ ActivateEvent,
20
+ BlobsReply,
21
+ Cell,
22
+ ChangeEvent,
23
+ EditEvent,
24
+ ErrorEvent,
25
+ Event,
26
+ FocusEvent,
27
+ GoneEvent,
28
+ HelloReply,
29
+ MotionEvent,
30
+ Reply,
31
+ ResizeEvent,
32
+ SelectEvent,
33
+ SendEvent,
34
+ ThemeEvent,
35
+ ToggleEvent,
36
+ UndoEvent,
37
+ UnknownEvent,
38
+ UnknownReply,
39
+ VisibleEvent,
40
+ )
41
+
42
+ __all__ = [
43
+ "DA1", "AckEvent", "ActionEvent", "ActivateEvent", "Answer", "AnyView", "BlobsReply",
44
+ "Capabilities", "Cell", "ChangeEvent", "Da1", "EditEvent", "ErrorEvent", "Event",
45
+ "FocusEvent", "GoneEvent", "HelloReply", "InputItem", "InputParser", "Key", "KeyDecoder",
46
+ "MotionEvent", "Node", "ReconcileError", "Reply", "ResizeEvent", "SelectEvent", "SendEvent",
47
+ "Session", "Surface", "ThemeEvent", "ToggleEvent", "UndoEvent", "UnknownEvent",
48
+ "UnknownReply", "Unsupported", "View", "VisibleEvent",
49
+ "ask", "available", "connect", "input", "keys", "plain", "reconcile", "session",
50
+ "show", "span", "start", "ui", "wire",
51
+ ] # fmt: skip
tern_sdk/helpers.py ADDED
@@ -0,0 +1,475 @@
1
+ """One-call helpers: `show` (static output kept in the scrollback), `ask`
2
+ (one question, its answer returned) and `plain` (the plain-text fallback)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ import sys
8
+ import unicodedata
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass, field
11
+ from typing import Final
12
+
13
+ from .keys import Key
14
+ from .reconcile import REGIONS, AnyView, Region, as_view
15
+ from .session import Session, available, connect
16
+ from .ui import Node
17
+ from .wire import ActionEvent, Json
18
+
19
+
20
+ class Unsupported(RuntimeError):
21
+ """`ask` can't ask: there is no TSP here, so the program asks its own way."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Answer:
26
+ """The `action` that submitted an `ask` form: node `id`, `act` and the form's `values`."""
27
+
28
+ id: str | None
29
+ act: str
30
+ values: dict[str, Json] = field(default_factory=dict)
31
+ event: ActionEvent | None = field(default=None, repr=False, compare=False)
32
+
33
+
34
+ def show(
35
+ view: AnyView,
36
+ *,
37
+ css: str | None = None,
38
+ fallback: str | None = None,
39
+ session: Session | None = None,
40
+ ) -> None:
41
+ """Shows a static view that stays in the scrollback like command output:
42
+ a `flow` surface opened with `listen: false`, its sheet, one frame and a
43
+ close with `keep`. Without TSP it prints `plain(view)`, or `fallback`."""
44
+ own = session is None
45
+ if session is None:
46
+ session = connect(paste=False, kitty=False) if available() else None
47
+ if session is None:
48
+ text = fallback if fallback is not None else plain(view)
49
+ sys.stdout.write(text if text.endswith("\n") or not text else text + "\n")
50
+ sys.stdout.flush()
51
+ return
52
+ try:
53
+ surface = session.open(mode="flow", listen=False)
54
+ if css is not None:
55
+ surface.stylesheet("main", css)
56
+ surface.render(as_view(view))
57
+ surface.close(keep=True)
58
+ finally:
59
+ if own:
60
+ session.close()
61
+
62
+
63
+ def _dismisses(key: Key) -> bool:
64
+ return key.name == "escape" or (key.ctrl and not key.alt and not key.meta and key.name in ("c", "d"))
65
+
66
+
67
+ def ask(
68
+ view: AnyView,
69
+ *,
70
+ css: str | None = None,
71
+ submit: str = "submit",
72
+ session: Session | None = None,
73
+ ) -> Answer | None:
74
+ """Shows a form in a `flow` surface and returns the first `action` whose
75
+ `act` is `submit`, or `None` when the user presses Escape, Ctrl+C or
76
+ Ctrl+D. The form stays in the scrollback; handlers on its nodes run while
77
+ it waits. Raises `Unsupported` without TSP."""
78
+ own = session is None
79
+ if session is None:
80
+ session = connect() if available() else None
81
+ if session is None:
82
+ raise Unsupported("no Tern Surface Protocol on this terminal")
83
+ try:
84
+ with session.open(mode="flow") as surface:
85
+ if css is not None:
86
+ surface.stylesheet("main", css)
87
+ surface.render(as_view(view))
88
+ surface._submit = submit
89
+ for item in session.input():
90
+ if isinstance(item, Key):
91
+ if _dismisses(item):
92
+ return None
93
+ elif isinstance(item, ActionEvent) and item.sf == surface.id and item.act == submit:
94
+ return Answer(item.id, submit, dict(item.values or {}), item)
95
+ return None
96
+ finally:
97
+ if own:
98
+ session.close()
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Plain text
103
+
104
+ _ANSI: Final = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[PX^_][^\x1b]*\x1b\\|[@-Z\\-_])")
105
+ _BLOCK_TAGS: Final = frozenset(
106
+ "div p section header footer nav aside main article figure blockquote ul ol li dl dt dd "
107
+ "h1 h2 h3 h4 pre hr table thead tbody tr form".split()
108
+ )
109
+
110
+
111
+ def plain(view: AnyView, cols: int = 80) -> str:
112
+ """Renders a view as readable plain text without escapes (fallback output,
113
+ not a copy of Tern's layout)."""
114
+ regions = as_view(view)
115
+ lines: list[str] = []
116
+ if regions is not None:
117
+ for name in REGIONS:
118
+ region = regions.get(name)
119
+ if region is not None:
120
+ lines += _render(_wire(region), max(cols, 8))
121
+ return "\n".join(line.rstrip() for line in lines)
122
+
123
+
124
+ def _wire(region: Region) -> Mapping[str, Json]:
125
+ if isinstance(region, Node):
126
+ return region.to_json()
127
+ if isinstance(region, Mapping):
128
+ return region
129
+ return {"k": "col", "c": [n.to_json() for n in region]}
130
+
131
+
132
+ def _width(s: str) -> int:
133
+ return sum(2 if unicodedata.east_asian_width(c) in "WF" else 0 if unicodedata.combining(c) else 1 for c in s)
134
+
135
+
136
+ def _pad(s: str, width: int, end: bool = False) -> str:
137
+ gap = " " * max(width - _width(s), 0)
138
+ return gap + s if end else s + gap
139
+
140
+
141
+ def _text(value: Json) -> str:
142
+ """Plain text of a string or spans."""
143
+ if isinstance(value, str):
144
+ return value
145
+ if isinstance(value, list):
146
+ out = []
147
+ for s in value:
148
+ if isinstance(s, str):
149
+ out.append(s)
150
+ elif isinstance(s, Mapping) and isinstance(s.get("t"), str) and "icon" not in str(s.get("s", "")).split():
151
+ out.append(s["t"])
152
+ return "".join(out)
153
+ return ""
154
+
155
+
156
+ def _node_text(p: Mapping[str, Json]) -> str:
157
+ return _text(p["spans"]) if isinstance(p.get("spans"), list) else _text(p.get("text"))
158
+
159
+
160
+ def _indent(lines: list[str], by: str = " ") -> list[str]:
161
+ return [by + line if line else line for line in lines]
162
+
163
+
164
+ def _bar(value: Json) -> str:
165
+ width = 10
166
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
167
+ v = min(max(float(value), 0.0), 1.0)
168
+ filled = int(v * width + 0.5)
169
+ return f"[{'#' * filled}{'-' * (width - filled)}] {int(v * 100 + 0.5)}%"
170
+ return f"[{'-' * width}]"
171
+
172
+
173
+ def _table(rows: list[list[str]], ends: Sequence[bool] = ()) -> list[str]:
174
+ if not rows:
175
+ return []
176
+ n = max(len(r) for r in rows)
177
+ widths = [max((_width(r[i]) for r in rows if i < len(r)), default=0) for i in range(n)]
178
+ out = []
179
+ for r in rows:
180
+ cells = [_pad(c, widths[i], i < len(ends) and ends[i]) for i, c in enumerate(r)]
181
+ out.append(" ".join(cells))
182
+ return out
183
+
184
+
185
+ def _children(node: Mapping[str, Json]) -> list[Mapping[str, Json]]:
186
+ c = node.get("c")
187
+ return [x for x in c if isinstance(x, Mapping)] if isinstance(c, list) else []
188
+
189
+
190
+ def _props(node: Mapping[str, Json]) -> Mapping[str, Json]:
191
+ p = node.get("p")
192
+ return p if isinstance(p, Mapping) else {}
193
+
194
+
195
+ def _block(lines: list[str], cols: int, children: list[Mapping[str, Json]]) -> list[str]:
196
+ for child in children:
197
+ lines += _render(child, cols)
198
+ return lines
199
+
200
+
201
+ def _render(node: Mapping[str, Json], cols: int) -> list[str]:
202
+ """A node as plain lines."""
203
+ k = node.get("k")
204
+ p = _props(node)
205
+ kids = _children(node)
206
+ if p.get("hidden") is True:
207
+ return []
208
+ match k:
209
+ case "col" | "status":
210
+ if k == "status":
211
+ return [" ".join(" ".join(_render(c, cols)) for c in kids)]
212
+ return _block([], cols, kids)
213
+ case "row":
214
+ parts = [" ".join(line for line in _render(c, cols) if line) for c in kids]
215
+ return [" ".join(part for part in parts if part)]
216
+ case "card" | "section" | "overlay":
217
+ head = _text(p.get("head"))
218
+ status = p.get("status")
219
+ if isinstance(status, str):
220
+ head = f"{head} ({status})" if head else f"({status})"
221
+ body = _indent(_block([], cols, kids))
222
+ return ([head] if head else []) + body
223
+ case "rule":
224
+ label = _text(p.get("label"))
225
+ if not label:
226
+ return ["-" * cols]
227
+ side = max((cols - _width(label) - 2) // 2, 2)
228
+ return [f"{'-' * side} {label} {'-' * side}"]
229
+ case "spacer":
230
+ return [""]
231
+ case "text" | "shimmer" | "seg":
232
+ seg = _node_text(p)
233
+ if k == "seg" and kids:
234
+ seg = " ".join([*(" ".join(_render(c, cols)) for c in kids), seg]).strip()
235
+ return seg.split("\n")
236
+ case "md" | "code" | "math" | "editor" | "input":
237
+ return _text(p.get("text")).split("\n")
238
+ case "ansi":
239
+ return _ANSI.sub("", _text(p.get("text"))).split("\n")
240
+ case "rows":
241
+ lines = p.get("lines")
242
+ return [_ANSI.sub("", s) for s in lines if isinstance(s, str)] if isinstance(lines, list) else []
243
+ case "diff":
244
+ hunks = p.get("hunks")
245
+ if isinstance(hunks, list):
246
+ return [
247
+ s for h in hunks if isinstance(h, Mapping) for s in (h.get("lines") or []) if isinstance(s, str)
248
+ ]
249
+ return _text(p.get("text")).split("\n")
250
+ case "kv":
251
+ items = [i for i in p.get("items") or [] if isinstance(i, Mapping)]
252
+ pairs = [[_text(i.get("k")), _text(i.get("v"))] for i in items]
253
+ if p.get("layout") == "inline":
254
+ return [" · ".join(f"{a} {b}" for a, b in pairs)]
255
+ return _table(pairs)
256
+ case "table":
257
+ return _plain_table(p, cols)
258
+ case "tree":
259
+ return _tree([n for n in p.get("nodes") or [] if isinstance(n, Mapping)])
260
+ case "badge":
261
+ return [f"[{_text(p.get('text'))}]"]
262
+ case "kbd":
263
+ return ["+".join(s for s in p.get("keys") or [] if isinstance(s, str))]
264
+ case "icon":
265
+ return []
266
+ case "image":
267
+ return [f"[image: {_text(p.get('alt')) or _text(p.get('title')) or 'image'}]"]
268
+ case "list":
269
+ out = _block([], cols, kids)
270
+ if not out and p.get("empty") is not None:
271
+ return [_text(p.get("empty"))]
272
+ return out
273
+ case "item":
274
+ parts = [_text(p.get(f)) for f in ("label", "detail", "value")]
275
+ return ["- " + " ".join(x for x in parts if x)]
276
+ case "tabs":
277
+ active = p.get("active")
278
+ tabs = [t for t in p.get("items") or [] if isinstance(t, Mapping)]
279
+ return [
280
+ " | ".join(
281
+ f"[{_text(t.get('label'))}]" if t.get("id") == active else _text(t.get("label")) for t in tabs
282
+ )
283
+ ]
284
+ case "picker":
285
+ title = _text(p.get("title"))
286
+ items = [i for i in p.get("items") or [] if isinstance(i, Mapping)]
287
+ return ([title] if title else []) + [f"- {_text(i.get('label')) or i.get('id')}" for i in items]
288
+ case "spinner":
289
+ return [_text(p.get("label"))]
290
+ case "elapsed":
291
+ ms = p.get("stopped", p.get("age", 0))
292
+ return [_elapsed(ms if isinstance(ms, (int, float)) else 0)]
293
+ case "rate":
294
+ unit = p.get("unit")
295
+ return [f"{p.get('value', 0)}{' ' + unit if isinstance(unit, str) else ''}"]
296
+ case "progress" | "meter":
297
+ value = p.get("value")
298
+ fills = p.get("parts")
299
+ if value is None and isinstance(fills, list):
300
+ value = sum(x.get("value", 0) for x in fills if isinstance(x, Mapping))
301
+ line = _bar(value)
302
+ extra = " ".join(x for x in (_text(p.get("label")), _text(p.get("total"))) if x)
303
+ return [f"{line} {extra}".rstrip()]
304
+ case "chart":
305
+ summary = _text(p.get("summary"))
306
+ return [summary] if summary else []
307
+ case "effort":
308
+ return [f"effort: {p.get('level')}"] if isinstance(p.get("level"), str) else []
309
+ case "toast":
310
+ return [" ".join(x for x in (_text(p.get("text")), _text(p.get("sub"))) if x)]
311
+ case "tool":
312
+ head = " ".join(x for x in (_text(p.get("title")) or _text(p.get("name")), _text(p.get("target"))) if x)
313
+ meta = [_text(m) for m in p.get("meta") or []]
314
+ status = p.get("status")
315
+ line = " ".join([head, *meta] + ([f"({status})"] if isinstance(status, str) else []))
316
+ return [line] + _indent(_block([], cols, kids))
317
+ case "agent":
318
+ status = p.get("status")
319
+ line = " ".join(
320
+ x
321
+ for x in (_text(p.get("name")), _text(p.get("task")), f"({status})" if isinstance(status, str) else "")
322
+ if x
323
+ )
324
+ return [line] + _indent(_block([], cols, kids))
325
+ case "checklist":
326
+ return _checklist(p)
327
+ case "prefs":
328
+ return _prefs(p)
329
+ case "el":
330
+ return _el(node, cols)
331
+ case _:
332
+ return _block([], cols, kids)
333
+
334
+
335
+ def _elapsed(ms: float) -> str:
336
+ s = abs(ms) / 1000
337
+ if s < 60:
338
+ return f"{int(s * 10) / 10:.1f}s"
339
+ if s < 3600:
340
+ return f"{int(s // 60)}m {int(s % 60):02d}s"
341
+ return f"{int(s // 3600)}h {int(s % 3600 // 60):02d}m"
342
+
343
+
344
+ def _plain_table(p: Mapping[str, Json], cols: int) -> list[str]:
345
+ columns = [c for c in p.get("cols") or [] if isinstance(c, Mapping) and isinstance(c.get("id"), str)]
346
+ rows = [r for r in p.get("rows") or [] if isinstance(r, Mapping)]
347
+ out: list[list[str]] = []
348
+ if any(_text(c.get("head")) for c in columns):
349
+ out.append([_text(c.get("head")) for c in columns])
350
+ for r in rows:
351
+ cells = r.get("cells") if isinstance(r.get("cells"), Mapping) else {}
352
+ line = []
353
+ for c in columns:
354
+ cell = cells.get(c["id"]) if isinstance(cells, Mapping) else None
355
+ if isinstance(cell, Mapping):
356
+ meter = cell.get("meter")
357
+ value = meter.get("value") if isinstance(meter, Mapping) else None
358
+ parts = meter.get("parts") if isinstance(meter, Mapping) else None
359
+ if value is None and isinstance(parts, list):
360
+ value = sum(part.get("value", 0) for part in parts if isinstance(part, Mapping))
361
+ line.append(_bar(value))
362
+ else:
363
+ line.append(_text(cell))
364
+ out.append(line)
365
+ return _table(out, [c.get("align") == "end" for c in columns])
366
+
367
+
368
+ def _tree(nodes: list[Mapping[str, Json]], depth: int = 0) -> list[str]:
369
+ out = []
370
+ for n in nodes:
371
+ out.append(" " * depth + "- " + _text(n.get("label")))
372
+ kids = n.get("children")
373
+ if isinstance(kids, list):
374
+ out += _tree([c for c in kids if isinstance(c, Mapping)], depth + 1)
375
+ return out
376
+
377
+
378
+ _CHECK: Final = {"done": "[x]", "dropped": "[-]", "active": "[>]", "blocked": "[!]"}
379
+
380
+
381
+ def _checklist(p: Mapping[str, Json]) -> list[str]:
382
+ out = []
383
+ for phase in p.get("phases") or []:
384
+ if not isinstance(phase, Mapping):
385
+ continue
386
+ title = _text(phase.get("title"))
387
+ if title:
388
+ out.append(title)
389
+ for item in phase.get("items") or []:
390
+ if isinstance(item, Mapping):
391
+ mark = _CHECK.get(str(item.get("status")), "[ ]")
392
+ out.append((" " if title else "") + f"{mark} {_text(item.get('text'))}")
393
+ return out
394
+
395
+
396
+ def _prefs(p: Mapping[str, Json]) -> list[str]:
397
+ out = [_text(p.get("title")) or "Settings"]
398
+ for section in p.get("sections") or []:
399
+ if not isinstance(section, Mapping):
400
+ continue
401
+ if _text(section.get("title")):
402
+ out.append(_text(section.get("title")))
403
+ for row in section.get("rows") or []:
404
+ if not isinstance(row, Mapping):
405
+ continue
406
+ control = row.get("control") if isinstance(row.get("control"), Mapping) else {}
407
+ value = control.get("value", control.get("on", "")) if isinstance(control, Mapping) else ""
408
+ out.append(f" {_text(row.get('label')) or row.get('id')}: {value}")
409
+ return out
410
+
411
+
412
+ def _el_inline(node: Mapping[str, Json]) -> str:
413
+ p = _props(node)
414
+ if node.get("k") != "el":
415
+ return " ".join(_render(node, 80))
416
+ tag = p.get("tag", "div")
417
+ if tag == "input":
418
+ on = p.get("checked") is True
419
+ return ("(*)" if on else "( )") if p.get("type") == "radio" else ("[x]" if on else "[ ]")
420
+ text = _text(p.get("text"))
421
+ control = False
422
+ for child in _children(node):
423
+ piece = _el_inline(child)
424
+ if control and piece and text:
425
+ text += " "
426
+ text += piece
427
+ control = child.get("k") == "el" and _props(child).get("tag") == "input"
428
+ if tag == "button":
429
+ return f"[ {text.strip()} ]"
430
+ return text
431
+
432
+
433
+ def _el(node: Mapping[str, Json], cols: int) -> list[str]:
434
+ p = _props(node)
435
+ tag = p.get("tag", "div")
436
+ if tag not in _BLOCK_TAGS:
437
+ return [_el_inline(node)]
438
+ if tag == "hr":
439
+ return ["-" * cols]
440
+ if tag in ("table", "thead", "tbody"):
441
+ rows: list[list[str]] = []
442
+ _collect_rows(node, rows)
443
+ return _table(rows)
444
+ if tag == "tr":
445
+ return [" ".join(_el_inline(c) for c in _children(node))]
446
+ lines: list[str] = []
447
+ cur = _text(p.get("text"))
448
+ ordered = 0
449
+ for child in _children(node):
450
+ cp = _props(child)
451
+ if child.get("k") == "el" and cp.get("tag", "div") not in _BLOCK_TAGS:
452
+ piece = _el_inline(child)
453
+ cur = f"{cur} {piece}" if cur and piece and not cur.endswith(" ") else cur + piece
454
+ continue
455
+ if cur:
456
+ lines.append(cur)
457
+ cur = ""
458
+ sub = _render(child, cols)
459
+ if child.get("k") == "el" and cp.get("tag") == "li":
460
+ ordered += 1
461
+ bullet = f"{ordered}. " if tag == "ol" else "- "
462
+ sub = [bullet + sub[0], *_indent(sub[1:])] if sub else [bullet.rstrip()]
463
+ lines += sub
464
+ if cur:
465
+ lines.append(cur)
466
+ return _indent(lines) if tag in ("blockquote", "dd", "figure") else lines
467
+
468
+
469
+ def _collect_rows(node: Mapping[str, Json], rows: list[list[str]]) -> None:
470
+ for child in _children(node):
471
+ tag = _props(child).get("tag")
472
+ if tag == "tr":
473
+ rows.append([_el_inline(c) for c in _children(child)])
474
+ elif tag in ("thead", "tbody"):
475
+ _collect_rows(child, rows)