substratum-cli 0.2.7__tar.gz → 0.2.8__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.
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/PKG-INFO +1 -1
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/__init__.py +1 -1
- substratum_cli-0.2.8/substratum_cli/_tui.py +435 -0
- substratum_cli-0.2.8/substratum_cli/_vendor/css_gate.py +246 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/codes.py +20 -0
- substratum_cli-0.2.8/substratum_cli/commands/css_fix.py +233 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/fix.py +12 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/verify.py +15 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/session.py +188 -18
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/PKG-INFO +1 -1
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/SOURCES.txt +3 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/README.md +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/pyproject.toml +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/setup.cfg +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/README.md +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/__main__.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_boot.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_update.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_vendor/__init__.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_vendor/_pro_test_derive.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_vendor/client_gate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/_wordmark.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/account.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/build_state.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/cli.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/__init__.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/apply.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/auth.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/commit.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/explain.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/failing.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/history.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/init_cmd.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/pr.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/recall.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/recheck.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/replay.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/rules.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/scaffold.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/show.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/undo.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/commands/write.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/config.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/diffs.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/engine.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/gitio.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/identity.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/langscan.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/product_ops.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/refusals.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/remote.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/render.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/result.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/rulebook.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/run_store.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/runid.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli/style.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/dependency_links.txt +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/entry_points.txt +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/requires.txt +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/substratum_cli.egg-info/top_level.txt +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_adaptive_prior.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_alias_warrant_trace.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_bidirectional_gate_contracts.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_coherence_evolution.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_coherences.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_connection_web_api.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_cross_entity_scoping.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_data.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_de28_negation_refuse.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_definite_description_handler.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_demo_regression_catalog.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_demo_response_shape.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_e1_e2_field_alignment.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_extraction_class_write_time.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_falsification_predictions.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_gap_d_wh_normalization.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_inference.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_inflection_cvc_rule.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_inflection_irregular.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_intervention.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_invariant.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_legal_citation_handler.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_lens_noop_seam.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_markup_tag_handler.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_nl_parser.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_nl_parser_adversarial.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_noise.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_pass2_contextual.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_perspectives.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_polysemous_token_alias_guard.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_possessive_normalization.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_predicate_canon.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_projection_gate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_projection_gate_tag_question.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_query_normalizer.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_records_answer_format.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_referent_aboutness_gate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_referent_quality_gate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_refuse_message_topic_neutrality.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_relation_detection.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_relaxation.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_residual_content_gate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_reverse_lookup_subject_filter.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_sec_statement_aliases.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_span_lens.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_span_lens_query_integration.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_substrate.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_synonymy_detector.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_table_ingest.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_triple_ingest.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_two_phase_schema.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_user_asserted_aliases.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_v2_api.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_v2_api_claims.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_v2_claim_determinism.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_word_role.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_word_role_wiring.py +0 -0
- {substratum_cli-0.2.7 → substratum_cli-0.2.8}/tests/test_xbrl_doc_aliases.py +0 -0
|
@@ -5,4 +5,4 @@ Import budget: this package is stdlib-only; the ONLY module reaching into backen
|
|
|
5
5
|
"""
|
|
6
6
|
# SINGLE SOURCE OF TRUTH for the release version. pyproject reads this (dynamic version), the boot shows
|
|
7
7
|
# it, and the update-check compares against it. To cut a release, bump ONLY this line, then publish.
|
|
8
|
-
__version__ = "0.2.
|
|
8
|
+
__version__ = "0.2.8"
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Substratum's interactive surfaces: the command PALETTE ("/") and the LOOP VIEWER (help).
|
|
2
|
+
|
|
3
|
+
Not a generic menu -- the instrument's own language: the ◆ brand mark is the selector, panels
|
|
4
|
+
carry the verdict rail (│ ... └──), verbs render PROVEN green, transitions gold. The help viewer's
|
|
5
|
+
tab bar IS the loop (1 understand ▸ 2 build ▸ 3 land ▸ 4 remember ▸ setup ▸ more): left/right
|
|
6
|
+
walks the workflow itself, the active stage lit, and YOUR live state pinned at the bottom.
|
|
7
|
+
|
|
8
|
+
Stdlib only (termios + ANSI), POSIX, drawn on the alternate screen so the session scrollback stays
|
|
9
|
+
pristine. Every entry point degrades gracefully off-TTY (returns None; callers fall back to the
|
|
10
|
+
static text). Keys: type to filter · up/down move · left/right stages · Enter select · Esc close.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List, Optional, Sequence, Tuple
|
|
17
|
+
|
|
18
|
+
from . import style as S
|
|
19
|
+
|
|
20
|
+
_ALT_ON = "\x1b[?1049h\x1b[H"
|
|
21
|
+
_ALT_OFF = "\x1b[?1049l"
|
|
22
|
+
_CLEAR = "\x1b[2J\x1b[H"
|
|
23
|
+
_HIDE = "\x1b[?25l"
|
|
24
|
+
_SHOW = "\x1b[?25h"
|
|
25
|
+
_R = S.RESET if hasattr(S, "RESET") else "\x1b[0m"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _term_size() -> Tuple[int, int]:
|
|
29
|
+
try:
|
|
30
|
+
sz = os.get_terminal_size()
|
|
31
|
+
return sz.columns, sz.lines
|
|
32
|
+
except OSError:
|
|
33
|
+
return 100, 30
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_PENDING: dict = {} # fd -> bytearray of pushed-back bytes
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _getch(fd: int, wait: float = None) -> bytes:
|
|
40
|
+
"""One byte, honoring pushback. wait=None blocks; wait=seconds returns b"" on timeout."""
|
|
41
|
+
q = _PENDING.get(fd)
|
|
42
|
+
if q:
|
|
43
|
+
b = bytes(q[:1])
|
|
44
|
+
del q[:1]
|
|
45
|
+
return b
|
|
46
|
+
if wait is not None:
|
|
47
|
+
import select
|
|
48
|
+
if not select.select([fd], [], [], wait)[0]:
|
|
49
|
+
return b""
|
|
50
|
+
return os.read(fd, 1)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _push(fd: int, b: bytes) -> None:
|
|
54
|
+
_PENDING.setdefault(fd, bytearray()).extend(b)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def read_key(fd: int) -> str:
|
|
58
|
+
"""One LOGICAL keypress: up/down/left/right/home/end/delete/enter/esc/backspace or the decoded
|
|
59
|
+
character. Byte-wise with pushback: a lone Esc never swallows the keys typed after it, and
|
|
60
|
+
escape sequences survive byte-at-a-time arrival (fast typing, ptys, slow ssh)."""
|
|
61
|
+
ch = _getch(fd)
|
|
62
|
+
if not ch:
|
|
63
|
+
raise EOFError
|
|
64
|
+
if ch == b"\x1b":
|
|
65
|
+
nxt = _getch(fd, wait=0.05)
|
|
66
|
+
if not nxt:
|
|
67
|
+
return "esc"
|
|
68
|
+
if nxt != b"[" and nxt != b"O":
|
|
69
|
+
_push(fd, nxt) # a lone Esc followed by a normal key: keep the key
|
|
70
|
+
return "esc"
|
|
71
|
+
fin = _getch(fd, wait=0.10) or b""
|
|
72
|
+
m = {b"A": "up", b"B": "down", b"C": "right", b"D": "left", b"H": "home", b"F": "end"}
|
|
73
|
+
if fin in m:
|
|
74
|
+
return m[fin]
|
|
75
|
+
if fin == b"3":
|
|
76
|
+
_getch(fd, wait=0.05) # the trailing ~ of delete
|
|
77
|
+
return "delete"
|
|
78
|
+
return "esc"
|
|
79
|
+
if ch in (b"\r", b"\n"):
|
|
80
|
+
return "enter"
|
|
81
|
+
if ch in (b"\x7f", b"\x08"):
|
|
82
|
+
return "backspace"
|
|
83
|
+
if ch == b"\x03":
|
|
84
|
+
raise KeyboardInterrupt
|
|
85
|
+
if ch == b"\x04":
|
|
86
|
+
return "ctrl-d"
|
|
87
|
+
if ch == b"\x01":
|
|
88
|
+
return "ctrl-a"
|
|
89
|
+
if ch == b"\x05":
|
|
90
|
+
return "ctrl-e"
|
|
91
|
+
if ch == b"\x15":
|
|
92
|
+
return "ctrl-u"
|
|
93
|
+
if ch == b"\x0b":
|
|
94
|
+
return "ctrl-k"
|
|
95
|
+
if ch == b"\x17":
|
|
96
|
+
return "ctrl-w"
|
|
97
|
+
if ch == b"\t":
|
|
98
|
+
return "tab"
|
|
99
|
+
return _read_utf8(fd, ch)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class _raw:
|
|
103
|
+
def __init__(self, fd: int):
|
|
104
|
+
self.fd = fd
|
|
105
|
+
|
|
106
|
+
def __enter__(self):
|
|
107
|
+
import termios
|
|
108
|
+
import tty
|
|
109
|
+
self.saved = termios.tcgetattr(self.fd)
|
|
110
|
+
tty.setcbreak(self.fd)
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *a):
|
|
114
|
+
import termios
|
|
115
|
+
termios.tcsetattr(self.fd, termios.TCSADRAIN, self.saved)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def tty_ok() -> bool:
|
|
119
|
+
return sys.stdin.isatty() and sys.stdout.isatty() and os.name == "posix"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def filter_items(items: Sequence[Tuple[str, str]], needle: str) -> List[Tuple[str, str]]:
|
|
123
|
+
"""The palette's live filter: name-prefix matches first, then substring (name or blurb)."""
|
|
124
|
+
n = needle.lower()
|
|
125
|
+
if not n:
|
|
126
|
+
return list(items)
|
|
127
|
+
pref = [i for i in items if i[0].lower().startswith(n)]
|
|
128
|
+
sub = [i for i in items if i not in pref
|
|
129
|
+
and (n in i[0].lower() or n in i[1].lower())]
|
|
130
|
+
return pref + sub
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def palette(items: Sequence[Tuple[str, str]]) -> Optional[str]:
|
|
134
|
+
"""The command palette: ◆-marked selection, verdict-rail panel, live filter. Returns the
|
|
135
|
+
selected command name, or None (Esc / off-TTY)."""
|
|
136
|
+
if not tty_ok():
|
|
137
|
+
return None
|
|
138
|
+
fd = sys.stdin.fileno()
|
|
139
|
+
needle, cursor = "", 0
|
|
140
|
+
out = sys.stdout
|
|
141
|
+
rail = f"{S.CHROME}{S.g('rail')}{_R}"
|
|
142
|
+
try:
|
|
143
|
+
out.write(_ALT_ON + _HIDE)
|
|
144
|
+
with _raw(fd):
|
|
145
|
+
while True:
|
|
146
|
+
cols, lines = _term_size()
|
|
147
|
+
view = filter_items(items, needle)
|
|
148
|
+
cursor = max(0, min(cursor, max(0, len(view) - 1)))
|
|
149
|
+
vis = max(4, lines - 7)
|
|
150
|
+
top = max(0, min(cursor - vis // 2, max(0, len(view) - vis)))
|
|
151
|
+
out.write(_CLEAR)
|
|
152
|
+
out.write(f" {S.GOLD}{S.g('brand')} substratum{_R} "
|
|
153
|
+
f"{S.LABEL}type to filter · ↵ choose · esc close{_R}\n")
|
|
154
|
+
out.write(f" {rail} {S.GOLD}/{_R}{needle}\n {rail}\n")
|
|
155
|
+
for i, (name, desc) in enumerate(view[top:top + vis]):
|
|
156
|
+
j = top + i
|
|
157
|
+
mark = f"{S.GOLD}{S.g('brand')}{_R}" if j == cursor else " "
|
|
158
|
+
verb = f"{S.PROVEN_B if j == cursor else S.PROVEN}{name.ljust(15)}{_R}"
|
|
159
|
+
blurb = desc[:max(10, cols - 26)]
|
|
160
|
+
tint = _R if j == cursor else S.LABEL
|
|
161
|
+
out.write(f" {rail} {mark} {verb} {tint}{blurb}{_R}\n")
|
|
162
|
+
if not view:
|
|
163
|
+
out.write(f" {rail} {S.LABEL}nothing matches {needle!r}{_R}\n")
|
|
164
|
+
out.write(f" {S.CHROME}{S.g('foot')}{S.g('bar') * 2}{_R}\n")
|
|
165
|
+
out.flush()
|
|
166
|
+
k = read_key(fd)
|
|
167
|
+
if k == "esc":
|
|
168
|
+
return None
|
|
169
|
+
if k == "enter":
|
|
170
|
+
return view[cursor][0] if view else None
|
|
171
|
+
if k == "up":
|
|
172
|
+
cursor -= 1
|
|
173
|
+
elif k == "down":
|
|
174
|
+
cursor += 1
|
|
175
|
+
elif k == "backspace":
|
|
176
|
+
needle = needle[:-1]
|
|
177
|
+
elif len(k) == 1 and k.isprintable():
|
|
178
|
+
needle += k
|
|
179
|
+
cursor = 0
|
|
180
|
+
finally:
|
|
181
|
+
out.write(_SHOW + _ALT_OFF)
|
|
182
|
+
out.flush()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def loop_viewer(stages, footer_line: str = "") -> Optional[str]:
|
|
186
|
+
"""The help menu: plain section tabs (active lit), ↑/↓ moves the ◆ through the section's
|
|
187
|
+
commands, Enter returns the picked verb (the caller runs it or prefills it -- parameterless
|
|
188
|
+
commands just run), ←/→ switches sections, Esc closes. One dim bottom line carries the keys +
|
|
189
|
+
the live state. Returns the verb or None; no-op off-TTY."""
|
|
190
|
+
if not tty_ok():
|
|
191
|
+
return None
|
|
192
|
+
fd = sys.stdin.fileno()
|
|
193
|
+
ti, sel = 0, 0
|
|
194
|
+
out = sys.stdout
|
|
195
|
+
rail = f"{S.CHROME}{S.g('rail')}{_R}"
|
|
196
|
+
try:
|
|
197
|
+
out.write(_ALT_ON + _HIDE)
|
|
198
|
+
with _raw(fd):
|
|
199
|
+
while True:
|
|
200
|
+
cols, lines = _term_size()
|
|
201
|
+
tabs = " ".join(
|
|
202
|
+
f"{S.GOLD}{n}{_R}" if i == ti else f"{S.LABEL}{n}{_R}"
|
|
203
|
+
for i, (n, _) in enumerate(stages))
|
|
204
|
+
rows = list(stages[ti][1])
|
|
205
|
+
sel = max(0, min(sel, len(rows) - 1))
|
|
206
|
+
vis = max(4, lines - 6)
|
|
207
|
+
top = max(0, min(sel - vis // 2, max(0, len(rows) - vis)))
|
|
208
|
+
out.write(_CLEAR)
|
|
209
|
+
out.write(f" {S.GOLD}{S.g('brand')} Help{_R} {tabs}\n {rail}\n")
|
|
210
|
+
for i, (cmd, desc) in enumerate(rows[top:top + vis]):
|
|
211
|
+
j = top + i
|
|
212
|
+
mark = f"{S.GOLD}{S.g('brand')}{_R}" if j == sel else " "
|
|
213
|
+
verb = f"{S.PROVEN_B if j == sel else S.PROVEN}{cmd.ljust(28)}{_R}"
|
|
214
|
+
tint = _R if j == sel else S.LABEL
|
|
215
|
+
out.write(f" {rail} {mark} {verb} {tint}{desc[:max(10, cols - 40)]}{_R}\n")
|
|
216
|
+
out.write(f" {S.CHROME}{S.g('foot')}{S.g('bar') * 2}{_R}\n")
|
|
217
|
+
keys = "↑↓ choose · ↵ run · ←→ sections · esc"
|
|
218
|
+
line = keys + (f" {S.g('dot')} {footer_line}" if footer_line else "")
|
|
219
|
+
out.write(f" {S.LABEL}{line[:cols - 3]}{_R}\n")
|
|
220
|
+
out.flush()
|
|
221
|
+
k = read_key(fd)
|
|
222
|
+
if k in ("esc", "q"):
|
|
223
|
+
return None
|
|
224
|
+
if k == "enter":
|
|
225
|
+
return rows[sel][0].split()[0].strip('"') if rows else None
|
|
226
|
+
if k == "right":
|
|
227
|
+
ti = (ti + 1) % len(stages)
|
|
228
|
+
sel = 0
|
|
229
|
+
elif k == "left":
|
|
230
|
+
ti = (ti - 1) % len(stages)
|
|
231
|
+
sel = 0
|
|
232
|
+
elif k == "up":
|
|
233
|
+
sel -= 1
|
|
234
|
+
elif k == "down":
|
|
235
|
+
sel += 1
|
|
236
|
+
finally:
|
|
237
|
+
out.write(_SHOW + _ALT_OFF)
|
|
238
|
+
out.flush()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def dropdown(fd: int, out, prompt: str, items) -> Optional[str]:
|
|
242
|
+
"""The Claude-Code-shaped dropdown, in Substratum's language: rendered INLINE under the live
|
|
243
|
+
prompt (never a screen takeover). Type to filter, up/down move (◆ marks the pick), Enter
|
|
244
|
+
returns the pick, Esc dismisses; either way every dropdown row is erased and the prompt line
|
|
245
|
+
is exactly where you left it."""
|
|
246
|
+
needle, cursor, prev_rows = "", 0, 0
|
|
247
|
+
VIS = 8
|
|
248
|
+
|
|
249
|
+
def render():
|
|
250
|
+
nonlocal prev_rows
|
|
251
|
+
view = filter_items(items, needle)
|
|
252
|
+
nonlocal cursor
|
|
253
|
+
cursor = max(0, min(cursor, max(0, len(view) - 1)))
|
|
254
|
+
top = max(0, min(cursor - VIS // 2, max(0, len(view) - VIS)))
|
|
255
|
+
rows = []
|
|
256
|
+
for i, (name, desc) in enumerate(view[top:top + VIS]):
|
|
257
|
+
j = top + i
|
|
258
|
+
mark = f"{S.GOLD}{S.g('brand')}{_R}" if j == cursor else " "
|
|
259
|
+
verb = f"{S.PROVEN_B if j == cursor else S.PROVEN}{name.ljust(15)}{_R}"
|
|
260
|
+
rows.append(f" {mark} {verb} {S.LABEL}{desc[:70]}{_R}")
|
|
261
|
+
if len(view) > VIS:
|
|
262
|
+
rows.append(f" {S.CHROME}{S.g('dot')} {len(view) - VIS} more -- keep typing{_R}")
|
|
263
|
+
if not view:
|
|
264
|
+
rows.append(f" {S.LABEL}no match -- ↵ runs /{needle} literally · esc closes{_R}")
|
|
265
|
+
# draw the prompt line, then the rows; pad with blank lines to erase stale ones
|
|
266
|
+
out.write("\r\x1b[K" + prompt + f"{S.GOLD}/{_R}" + needle)
|
|
267
|
+
n = max(len(rows), prev_rows)
|
|
268
|
+
for i in range(n):
|
|
269
|
+
out.write("\n\x1b[K" + (rows[i] if i < len(rows) else ""))
|
|
270
|
+
out.write(f"\x1b[{n}A" + "\r\x1b[K" + prompt + f"{S.GOLD}/{_R}" + needle)
|
|
271
|
+
out.flush()
|
|
272
|
+
prev_rows = len(rows)
|
|
273
|
+
return view
|
|
274
|
+
|
|
275
|
+
def erase():
|
|
276
|
+
n = prev_rows
|
|
277
|
+
for _ in range(n):
|
|
278
|
+
out.write("\n\x1b[K")
|
|
279
|
+
if n:
|
|
280
|
+
out.write(f"\x1b[{n}A")
|
|
281
|
+
out.write("\r\x1b[K")
|
|
282
|
+
out.flush()
|
|
283
|
+
|
|
284
|
+
view = render()
|
|
285
|
+
while True:
|
|
286
|
+
k = read_key(fd)
|
|
287
|
+
if k == "esc":
|
|
288
|
+
erase()
|
|
289
|
+
return None
|
|
290
|
+
if k == "enter":
|
|
291
|
+
pick = view[cursor][0] if view else ("/" + needle if needle else None)
|
|
292
|
+
erase()
|
|
293
|
+
return pick
|
|
294
|
+
if k == "up":
|
|
295
|
+
cursor -= 1
|
|
296
|
+
elif k == "down":
|
|
297
|
+
cursor += 1
|
|
298
|
+
elif k == "backspace":
|
|
299
|
+
if not needle: # backspacing past the "/" closes the dropdown
|
|
300
|
+
erase()
|
|
301
|
+
return None
|
|
302
|
+
needle = needle[:-1]
|
|
303
|
+
cursor = 0
|
|
304
|
+
elif len(k) == 1 and k.isprintable():
|
|
305
|
+
needle += k
|
|
306
|
+
cursor = 0
|
|
307
|
+
view = render()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---- the prompt editor: readline-feel, with "/" opening the dropdown INSTANTLY ------------------
|
|
311
|
+
|
|
312
|
+
def _read_utf8(fd: int, first: bytes) -> str:
|
|
313
|
+
"""Complete a UTF-8 char whose first byte was already read (paste-safe)."""
|
|
314
|
+
b = first[0]
|
|
315
|
+
n = 1 if b < 0x80 else (2 if b < 0xE0 else (3 if b < 0xF0 else 4))
|
|
316
|
+
data = first + (os.read(fd, n - 1) if n > 1 else b"")
|
|
317
|
+
try:
|
|
318
|
+
return data.decode("utf-8")
|
|
319
|
+
except UnicodeDecodeError:
|
|
320
|
+
return ""
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def read_line(prompt: str, history: list, complete_words, palette_items, state: dict,
|
|
324
|
+
should_submit=None) -> str:
|
|
325
|
+
"""A raw-mode line editor for the MAIN prompt. Readline-feel for the common keys (chars,
|
|
326
|
+
backspace/delete, arrows, home/end, ctrl-a/e/u/k/w, up/down history, tab word-complete,
|
|
327
|
+
ctrl-c clears via KeyboardInterrupt, ctrl-d on empty raises EOFError) -- and pressing "/" on
|
|
328
|
+
an EMPTY line opens the inline dropdown immediately, inserting the pick into the live buffer.
|
|
329
|
+
|
|
330
|
+
`state` is a shared dict async notices read ({"buffer": str}); a "prefill" key seeds the line."""
|
|
331
|
+
fd = sys.stdin.fileno()
|
|
332
|
+
out = sys.stdout
|
|
333
|
+
submit = state.pop("submit", "")
|
|
334
|
+
if submit: # a picked parameterless command: run it
|
|
335
|
+
out.write("\r\x1b[K" + prompt + submit + "\n")
|
|
336
|
+
out.flush()
|
|
337
|
+
state["buffer"] = ""
|
|
338
|
+
return submit
|
|
339
|
+
buf = state.pop("prefill", "")
|
|
340
|
+
buf = (buf + " ") if buf else ""
|
|
341
|
+
cur = len(buf)
|
|
342
|
+
hist_i, stash = len(history), ""
|
|
343
|
+
|
|
344
|
+
def draw():
|
|
345
|
+
state["buffer"] = buf
|
|
346
|
+
out.write("\r\x1b[K" + prompt + buf)
|
|
347
|
+
if cur < len(buf):
|
|
348
|
+
out.write(f"\x1b[{len(buf) - cur}D")
|
|
349
|
+
out.flush()
|
|
350
|
+
|
|
351
|
+
draw()
|
|
352
|
+
with _raw(fd):
|
|
353
|
+
if state.pop("open_dropdown", False): # a handed-off "/" opens it right here
|
|
354
|
+
pick = dropdown(fd, out, prompt, palette_items())
|
|
355
|
+
if pick and pick.startswith("/"):
|
|
356
|
+
out.write("\r\x1b[K" + prompt + pick + "\n")
|
|
357
|
+
out.flush()
|
|
358
|
+
state["buffer"] = ""
|
|
359
|
+
return pick
|
|
360
|
+
if pick:
|
|
361
|
+
buf = pick + " "
|
|
362
|
+
cur = len(buf)
|
|
363
|
+
draw()
|
|
364
|
+
while True:
|
|
365
|
+
k = read_key(fd)
|
|
366
|
+
if k == "ctrl-d":
|
|
367
|
+
if not buf:
|
|
368
|
+
raise EOFError
|
|
369
|
+
continue
|
|
370
|
+
if k == "enter":
|
|
371
|
+
out.write("\n")
|
|
372
|
+
out.flush()
|
|
373
|
+
state["buffer"] = ""
|
|
374
|
+
return buf
|
|
375
|
+
if k == "up":
|
|
376
|
+
if hist_i > 0:
|
|
377
|
+
if hist_i == len(history):
|
|
378
|
+
stash = buf
|
|
379
|
+
hist_i -= 1
|
|
380
|
+
buf = history[hist_i]
|
|
381
|
+
cur = len(buf)
|
|
382
|
+
elif k == "down":
|
|
383
|
+
if hist_i < len(history):
|
|
384
|
+
hist_i += 1
|
|
385
|
+
buf = history[hist_i] if hist_i < len(history) else stash
|
|
386
|
+
cur = len(buf)
|
|
387
|
+
elif k == "right":
|
|
388
|
+
cur = min(len(buf), cur + 1)
|
|
389
|
+
elif k == "left":
|
|
390
|
+
cur = max(0, cur - 1)
|
|
391
|
+
elif k == "home" or k == "ctrl-a":
|
|
392
|
+
cur = 0
|
|
393
|
+
elif k == "end" or k == "ctrl-e":
|
|
394
|
+
cur = len(buf)
|
|
395
|
+
elif k == "delete":
|
|
396
|
+
if cur < len(buf):
|
|
397
|
+
buf = buf[:cur] + buf[cur + 1:]
|
|
398
|
+
elif k == "backspace":
|
|
399
|
+
if cur > 0:
|
|
400
|
+
buf = buf[:cur - 1] + buf[cur:]
|
|
401
|
+
cur -= 1
|
|
402
|
+
elif k == "ctrl-u":
|
|
403
|
+
buf, cur = "", 0
|
|
404
|
+
elif k == "ctrl-k":
|
|
405
|
+
buf = buf[:cur]
|
|
406
|
+
elif k == "ctrl-w":
|
|
407
|
+
left = buf[:cur].rstrip()
|
|
408
|
+
cut = left.rfind(" ") + 1
|
|
409
|
+
buf, cur = buf[:cut] + buf[cur:], cut
|
|
410
|
+
elif k == "tab":
|
|
411
|
+
head = buf[:cur]
|
|
412
|
+
if " " not in head.strip():
|
|
413
|
+
cands = [w for w in complete_words() if w.startswith(head.strip())]
|
|
414
|
+
if len(cands) == 1:
|
|
415
|
+
buf = cands[0] + " " + buf[cur:].lstrip()
|
|
416
|
+
cur = len(cands[0]) + 1
|
|
417
|
+
elif len(cands) > 1:
|
|
418
|
+
out.write("\n " + " ".join(cands[:14]) + "\n")
|
|
419
|
+
elif k == "esc":
|
|
420
|
+
pass
|
|
421
|
+
elif k == "/" and not buf: # the dropdown: instant, inline
|
|
422
|
+
pick = dropdown(fd, out, prompt, palette_items())
|
|
423
|
+
if pick and (pick.startswith("/")
|
|
424
|
+
or (should_submit and should_submit(pick))):
|
|
425
|
+
out.write("\r\x1b[K" + prompt + pick + "\n")
|
|
426
|
+
out.flush()
|
|
427
|
+
state["buffer"] = ""
|
|
428
|
+
return pick # slash-commands + parameterless: run
|
|
429
|
+
if pick:
|
|
430
|
+
buf = pick + " "
|
|
431
|
+
cur = len(buf)
|
|
432
|
+
elif len(k) >= 1 and k.isprintable():
|
|
433
|
+
buf = buf[:cur] + k + buf[cur:]
|
|
434
|
+
cur += len(k)
|
|
435
|
+
draw()
|