substratum-cli 0.2.6__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.
Files changed (121) hide show
  1. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/PKG-INFO +1 -1
  2. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/__init__.py +1 -1
  3. substratum_cli-0.2.8/substratum_cli/_tui.py +435 -0
  4. substratum_cli-0.2.8/substratum_cli/_vendor/css_gate.py +246 -0
  5. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/build_state.py +56 -9
  6. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/cli.py +69 -3
  7. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/codes.py +24 -2
  8. substratum_cli-0.2.8/substratum_cli/commands/apply.py +95 -0
  9. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/auth.py +1 -1
  10. substratum_cli-0.2.8/substratum_cli/commands/css_fix.py +233 -0
  11. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/fix.py +12 -0
  12. substratum_cli-0.2.8/substratum_cli/commands/history.py +84 -0
  13. substratum_cli-0.2.8/substratum_cli/commands/recall.py +91 -0
  14. substratum_cli-0.2.8/substratum_cli/commands/recheck.py +89 -0
  15. substratum_cli-0.2.8/substratum_cli/commands/rules.py +45 -0
  16. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/verify.py +15 -0
  17. substratum_cli-0.2.8/substratum_cli/identity.py +56 -0
  18. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/refusals.py +7 -0
  19. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/render.py +147 -3
  20. substratum_cli-0.2.8/substratum_cli/rulebook.py +101 -0
  21. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/run_store.py +79 -0
  22. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/session.py +512 -58
  23. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/PKG-INFO +1 -1
  24. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/SOURCES.txt +8 -0
  25. substratum_cli-0.2.6/substratum_cli/commands/apply.py +0 -51
  26. substratum_cli-0.2.6/substratum_cli/commands/history.py +0 -15
  27. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/README.md +0 -0
  28. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/pyproject.toml +0 -0
  29. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/setup.cfg +0 -0
  30. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/README.md +0 -0
  31. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/__main__.py +0 -0
  32. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_boot.py +0 -0
  33. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_update.py +0 -0
  34. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_vendor/__init__.py +0 -0
  35. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_vendor/_pro_test_derive.py +0 -0
  36. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_vendor/client_gate.py +0 -0
  37. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/_wordmark.py +0 -0
  38. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/account.py +0 -0
  39. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/__init__.py +0 -0
  40. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/commit.py +0 -0
  41. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/explain.py +0 -0
  42. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/failing.py +0 -0
  43. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/init_cmd.py +0 -0
  44. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/pr.py +0 -0
  45. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/replay.py +0 -0
  46. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/scaffold.py +0 -0
  47. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/show.py +0 -0
  48. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/undo.py +0 -0
  49. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/commands/write.py +0 -0
  50. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/config.py +0 -0
  51. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/diffs.py +0 -0
  52. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/engine.py +0 -0
  53. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/gitio.py +0 -0
  54. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/langscan.py +0 -0
  55. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/product_ops.py +0 -0
  56. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/remote.py +0 -0
  57. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/result.py +0 -0
  58. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/runid.py +0 -0
  59. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli/style.py +0 -0
  60. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/dependency_links.txt +0 -0
  61. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/entry_points.txt +0 -0
  62. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/requires.txt +0 -0
  63. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/substratum_cli.egg-info/top_level.txt +0 -0
  64. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_adaptive_prior.py +0 -0
  65. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_alias_warrant_trace.py +0 -0
  66. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_bidirectional_gate_contracts.py +0 -0
  67. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_coherence_evolution.py +0 -0
  68. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_coherences.py +0 -0
  69. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_connection_web_api.py +0 -0
  70. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_cross_entity_scoping.py +0 -0
  71. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_data.py +0 -0
  72. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_de28_negation_refuse.py +0 -0
  73. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_definite_description_handler.py +0 -0
  74. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_demo_regression_catalog.py +0 -0
  75. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_demo_response_shape.py +0 -0
  76. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_e1_e2_field_alignment.py +0 -0
  77. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_extraction_class_write_time.py +0 -0
  78. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_falsification_predictions.py +0 -0
  79. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_gap_d_wh_normalization.py +0 -0
  80. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_inference.py +0 -0
  81. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_inflection_cvc_rule.py +0 -0
  82. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_inflection_irregular.py +0 -0
  83. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_intervention.py +0 -0
  84. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_invariant.py +0 -0
  85. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_legal_citation_handler.py +0 -0
  86. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_lens_noop_seam.py +0 -0
  87. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_markup_tag_handler.py +0 -0
  88. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_nl_parser.py +0 -0
  89. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_nl_parser_adversarial.py +0 -0
  90. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_noise.py +0 -0
  91. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_pass2_contextual.py +0 -0
  92. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_perspectives.py +0 -0
  93. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_polysemous_token_alias_guard.py +0 -0
  94. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_possessive_normalization.py +0 -0
  95. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_predicate_canon.py +0 -0
  96. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_projection_gate.py +0 -0
  97. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_projection_gate_tag_question.py +0 -0
  98. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_query_normalizer.py +0 -0
  99. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_records_answer_format.py +0 -0
  100. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_referent_aboutness_gate.py +0 -0
  101. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_referent_quality_gate.py +0 -0
  102. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_refuse_message_topic_neutrality.py +0 -0
  103. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_relation_detection.py +0 -0
  104. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_relaxation.py +0 -0
  105. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_residual_content_gate.py +0 -0
  106. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_reverse_lookup_subject_filter.py +0 -0
  107. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_sec_statement_aliases.py +0 -0
  108. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_span_lens.py +0 -0
  109. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_span_lens_query_integration.py +0 -0
  110. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_substrate.py +0 -0
  111. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_synonymy_detector.py +0 -0
  112. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_table_ingest.py +0 -0
  113. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_triple_ingest.py +0 -0
  114. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_two_phase_schema.py +0 -0
  115. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_user_asserted_aliases.py +0 -0
  116. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_v2_api.py +0 -0
  117. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_v2_api_claims.py +0 -0
  118. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_v2_claim_determinism.py +0 -0
  119. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_word_role.py +0 -0
  120. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_word_role_wiring.py +0 -0
  121. {substratum_cli-0.2.6 → substratum_cli-0.2.8}/tests/test_xbrl_doc_aliases.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: substratum-cli
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Substratum: verified-or-refused code, with provenance. The terminal over the comprehension engine.
5
5
  Author: Substratum
6
6
  License: LicenseRef-Proprietary
@@ -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.6"
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()