induscode 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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
@@ -0,0 +1,566 @@
1
+ """SGR painter — a table-driven ANSI Select-Graphic-Rendition state machine
2
+ that converts a terminal byte stream into a sequence of styled HTML spans.
3
+
4
+ Port of TS ``src/transcript-export/sgr.ts`` (verbatim logic).
5
+
6
+ Terminal programs encode color and emphasis with CSI sequences of the form
7
+ ``ESC [ p1 ; p2 ; … m`` (the trailing ``m`` is the SGR final byte). The
8
+ parameters are small integers whose meaning is fixed by ECMA-48 / ISO 6429:
9
+ ``0`` resets, ``1``/``3``/``4`` switch on weight/italic/underline, ``30``–``37``
10
+ and ``90``–``97`` set the foreground from the sixteen-color palette,
11
+ ``40``–``47`` and ``100``–``107`` set the background, and the two extended
12
+ introducers ``38``/``48`` pull a 256-index or a 24-bit triple out of the
13
+ following parameters.
14
+
15
+ Rather than a long ``switch``, the semantics live in a *table*: each handled
16
+ code maps to an :data:`~induscode.transcript_export.contract.SgrMutation` —
17
+ the partial :class:`~induscode.transcript_export.contract.SgrState` that code
18
+ imposes — and the painter folds the matched mutation over the running state.
19
+ Extending the machine is adding a row, never editing control flow. The two
20
+ extended introducers are the only parametric cases and are handled by a small
21
+ reader that consumes their trailing parameters.
22
+
23
+ The pipeline is three stages:
24
+
25
+ 1. :func:`tokenize_sgr` splits the raw stream into an alternation of text
26
+ runs and SGR parameter lists (:data:`SgrToken` values).
27
+ 2. :func:`fold_sgr` folds an SGR parameter list into the running
28
+ :class:`SgrState` using the :data:`SGR_CODE_TABLE` dispatch table.
29
+ 3. :func:`paint_sgr` drives the two over the whole stream, wrapping each text
30
+ run in a ``<span>`` whose class list and inline color style reflect the
31
+ state in force.
32
+
33
+ The :class:`SgrState` / :data:`SgrToken` / :data:`SgrMutation` vocabulary is
34
+ the transcript-export contract's; the SGR code meanings are the published
35
+ standard's.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import re
41
+ from dataclasses import replace
42
+ from types import MappingProxyType
43
+ from typing import Any, Final, Mapping, cast
44
+
45
+ from .contract import (
46
+ SGR_INITIAL_STATE,
47
+ SgrCommandToken,
48
+ SgrMutation,
49
+ SgrState,
50
+ SgrTextToken,
51
+ SgrToken,
52
+ )
53
+
54
+ __all__ = [
55
+ "SGR_CODE_TABLE",
56
+ "fold_sgr",
57
+ "paint_sgr",
58
+ "tokenize_sgr",
59
+ ]
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Palette
64
+ # ---------------------------------------------------------------------------
65
+
66
+ #: The eight base ANSI colors, in palette order (indices 0–7). The bright
67
+ #: variants (8–15) are the same hues lifted toward white. Values are concrete
68
+ #: CSS colors so the emitted spans are self-contained — the page need not ship
69
+ #: an ANSI palette to render them.
70
+ _BASE_PALETTE: Final[tuple[str, ...]] = (
71
+ "#1c1c1c", # black
72
+ "#c14a4a", # red
73
+ "#5aa45a", # green
74
+ "#b5963c", # yellow
75
+ "#4a7fc1", # blue
76
+ "#a05aa0", # magenta
77
+ "#42a0a0", # cyan
78
+ "#c9c9c9", # white
79
+ )
80
+
81
+ #: The eight bright ANSI colors, in palette order (indices 8–15) — the base
82
+ #: hues pushed brighter, used by the ``90``–``97`` / ``100``–``107`` parameter
83
+ #: ranges and by the high half of the 256-color cube's first sixteen entries.
84
+ _BRIGHT_PALETTE: Final[tuple[str, ...]] = (
85
+ "#5c5c5c", # bright black (gray)
86
+ "#e06666", # bright red
87
+ "#86d186", # bright green
88
+ "#e6cc66", # bright yellow
89
+ "#6fa8e6", # bright blue
90
+ "#c986c9", # bright magenta
91
+ "#6fcccc", # bright cyan
92
+ "#f5f5f5", # bright white
93
+ )
94
+
95
+ #: The full sixteen-entry indexed palette: base 0–7 then bright 8–15.
96
+ _PALETTE_16: Final[tuple[str, ...]] = _BASE_PALETTE + _BRIGHT_PALETTE
97
+
98
+
99
+ def _clamp_byte(value: int) -> int:
100
+ """Clamp an integer into the 0–255 byte range."""
101
+ if value < 0:
102
+ return 0
103
+ if value > 255:
104
+ return 255
105
+ return int(value)
106
+
107
+
108
+ def _hex_pair(value: int) -> str:
109
+ """Render a 0–255 channel as a two-digit lowercase hex pair."""
110
+ return format(_clamp_byte(value), "02x")
111
+
112
+
113
+ def _rgb_hex(r: int, g: int, b: int) -> str:
114
+ """Compose an ``#rrggbb`` CSS color from three 0–255 channels."""
115
+ return f"#{_hex_pair(r)}{_hex_pair(g)}{_hex_pair(b)}"
116
+
117
+
118
+ def _color_256(index: int) -> str:
119
+ """Resolve a 256-color palette index to a CSS color, per the standard
120
+ layout:
121
+
122
+ - ``0–15`` the sixteen indexed colors above;
123
+ - ``16–231`` a 6×6×6 RGB cube, each axis stepping through the six levels
124
+ ``{0, 95, 135, 175, 215, 255}``;
125
+ - ``232–255`` a 24-step neutral gray ramp from near-black to near-white.
126
+ """
127
+ i = _clamp_byte(index)
128
+ if i < 16:
129
+ return _PALETTE_16[i]
130
+ if i < 232:
131
+ c = i - 16
132
+ levels = (0, 95, 135, 175, 215, 255)
133
+ r = levels[(c // 36) % 6]
134
+ g = levels[(c // 6) % 6]
135
+ b = levels[c % 6]
136
+ return _rgb_hex(r, g, b)
137
+ step = 8 + (i - 232) * 10
138
+ return _rgb_hex(step, step, step)
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Code table
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ def _build_code_table() -> dict[int, SgrMutation]:
147
+ """Build the fixed-meaning portion of the SGR dispatch table. The two
148
+ color-range runs (``30``–``37`` foreground, ``90``–``97`` bright
149
+ foreground, and the background counterparts) are generated from the
150
+ palette so the table stays a single source rather than a wall of literal
151
+ rows.
152
+ """
153
+ table: dict[int, SgrMutation] = {}
154
+
155
+ # Reset: return to exactly the neutral starting style. Stored as a full
156
+ # copy of the initial state so the fold replaces every field in one step.
157
+ table[0] = {
158
+ "fg": SGR_INITIAL_STATE.fg,
159
+ "bg": SGR_INITIAL_STATE.bg,
160
+ "bold": SGR_INITIAL_STATE.bold,
161
+ "dim": SGR_INITIAL_STATE.dim,
162
+ "italic": SGR_INITIAL_STATE.italic,
163
+ "underline": SGR_INITIAL_STATE.underline,
164
+ "blink": SGR_INITIAL_STATE.blink,
165
+ "inverse": SGR_INITIAL_STATE.inverse,
166
+ "hidden": SGR_INITIAL_STATE.hidden,
167
+ "strike": SGR_INITIAL_STATE.strike,
168
+ }
169
+
170
+ # Attribute on-switches.
171
+ table[1] = {"bold": True}
172
+ table[2] = {"dim": True}
173
+ table[3] = {"italic": True}
174
+ table[4] = {"underline": True}
175
+ table[5] = {"blink": True}
176
+ table[6] = {"blink": True} # rapid blink — folded to the same hook
177
+ table[7] = {"inverse": True}
178
+ table[8] = {"hidden": True}
179
+ table[9] = {"strike": True}
180
+
181
+ # Attribute off-switches (the standard's single-attribute resets).
182
+ table[21] = {"bold": False} # doubly-underline in some terminals; treated as bold-off
183
+ table[22] = {"bold": False, "dim": False}
184
+ table[23] = {"italic": False}
185
+ table[24] = {"underline": False}
186
+ table[25] = {"blink": False}
187
+ table[27] = {"inverse": False}
188
+ table[28] = {"hidden": False}
189
+ table[29] = {"strike": False}
190
+
191
+ # Foreground: 30–37 base, 90–97 bright; default at 39.
192
+ for n in range(8):
193
+ table[30 + n] = {"fg": _BASE_PALETTE[n]}
194
+ table[90 + n] = {"fg": _BRIGHT_PALETTE[n]}
195
+ table[39] = {"fg": None}
196
+
197
+ # Background: 40–47 base, 100–107 bright; default at 49.
198
+ for n in range(8):
199
+ table[40 + n] = {"bg": _BASE_PALETTE[n]}
200
+ table[100 + n] = {"bg": _BRIGHT_PALETTE[n]}
201
+ table[49] = {"bg": None}
202
+
203
+ return table
204
+
205
+
206
+ #: The read-only public view of the SGR dispatch table — each fixed-meaning
207
+ #: SGR code mapped to the :data:`SgrMutation` it imposes. Exported so callers
208
+ #: can introspect the handled code space (the extended ``38``/``48``
209
+ #: introducers are not present here; they are parametric and resolved by the
210
+ #: painter).
211
+ SGR_CODE_TABLE: Final[Mapping[int, SgrMutation]] = MappingProxyType(_build_code_table())
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Extended color introducers (38 / 48)
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def _read_extended_color(
220
+ params: tuple[int, ...], introducer_index: int
221
+ ) -> tuple[str | None, int]:
222
+ """Read an extended-color value starting at the parameter after a ``38``
223
+ or ``48`` introducer. Returns the resolved CSS color (or ``None`` for an
224
+ unsupported mode) together with the number of parameters consumed
225
+ *including the introducer*, so the fold can advance its cursor correctly.
226
+
227
+ The two recognized sub-forms are the standard ones:
228
+
229
+ - ``…;5;n`` a single index into the 256-color palette;
230
+ - ``…;2;r;g;b`` a direct 24-bit triple.
231
+
232
+ A malformed or unsupported selector consumes only the introducer and
233
+ yields no color change, so a bad sequence degrades to a no-op rather than
234
+ corrupting the scan.
235
+ """
236
+
237
+ def at(index: int) -> int | None:
238
+ return params[index] if 0 <= index < len(params) else None
239
+
240
+ selector = at(introducer_index + 1)
241
+ if selector == 5:
242
+ index = at(introducer_index + 2)
243
+ if index is None:
244
+ return None, 1
245
+ return _color_256(index), 3
246
+ if selector == 2:
247
+ r = at(introducer_index + 2)
248
+ g = at(introducer_index + 3)
249
+ b = at(introducer_index + 4)
250
+ if r is None or g is None or b is None:
251
+ return None, 1
252
+ return _rgb_hex(r, g, b), 5
253
+ return None, 1
254
+
255
+
256
+ # ---------------------------------------------------------------------------
257
+ # Fold
258
+ # ---------------------------------------------------------------------------
259
+
260
+
261
+ def fold_sgr(state: SgrState, params: tuple[int, ...]) -> SgrState:
262
+ """Fold one SGR parameter list into a running :class:`SgrState`,
263
+ returning the next state.
264
+
265
+ Walks the parameters left to right. A fixed-meaning code is resolved
266
+ through the code table and its mutation merged over the state. The two
267
+ extended introducers ``38`` (foreground) and ``48`` (background) are
268
+ handled inline: the reader consumes their trailing ``5;n`` or ``2;r;g;b``
269
+ parameters and the resolved color is written to the matching channel. An
270
+ empty list is the bare ``ESC[m`` form and is treated as a reset, matching
271
+ terminal behavior.
272
+
273
+ Pure: the input state is never mutated; a fresh value is returned.
274
+
275
+ :param state: the style in force before this sequence
276
+ :param params: the numeric parameter list of one SGR sequence
277
+ """
278
+ if len(params) == 0:
279
+ return replace(SGR_INITIAL_STATE)
280
+
281
+ next_state = state
282
+ i = 0
283
+ while i < len(params):
284
+ code = params[i]
285
+
286
+ if code in (38, 48):
287
+ color, consumed = _read_extended_color(params, i)
288
+ if color is not None:
289
+ next_state = (
290
+ replace(next_state, fg=color)
291
+ if code == 38
292
+ else replace(next_state, bg=color)
293
+ )
294
+ i += consumed # skip the introducer's trailing parameters
295
+ continue
296
+
297
+ mutation = SGR_CODE_TABLE.get(code)
298
+ if mutation is not None:
299
+ next_state = replace(next_state, **cast("dict[str, Any]", dict(mutation)))
300
+ i += 1
301
+ return next_state
302
+
303
+
304
+ # ---------------------------------------------------------------------------
305
+ # Tokenizer
306
+ # ---------------------------------------------------------------------------
307
+
308
+ #: The escape byte that opens a control sequence.
309
+ _ESC: Final[str] = "\x1b"
310
+
311
+
312
+ def tokenize_sgr(input_text: str) -> list[SgrToken]:
313
+ """Split a raw terminal stream into an ordered list of :data:`SgrToken`
314
+ values.
315
+
316
+ Scans for CSI sequences of the form ``ESC [ … m``. Each such sequence
317
+ becomes an ``sgr`` token carrying its parsed numeric parameters (an empty
318
+ selector yields an empty list, the bare-reset form). The runs of ordinary
319
+ characters between sequences become ``text`` tokens. Non-SGR control
320
+ sequences — any CSI whose final byte is not ``m``, plus bare escapes — are
321
+ dropped: they carry cursor and screen commands that have no place in
322
+ flowed HTML, so swallowing them keeps the output clean while preserving
323
+ every printable character.
324
+
325
+ Empty ``text`` runs are never emitted, so the token stream is dense.
326
+
327
+ :param input_text: the raw ANSI/terminal byte stream
328
+ """
329
+ tokens: list[SgrToken] = []
330
+ text_start = 0
331
+ i = 0
332
+ n = len(input_text)
333
+
334
+ def flush_text(end: int) -> None:
335
+ if end > text_start:
336
+ tokens.append(SgrTextToken(text=input_text[text_start:end]))
337
+
338
+ while i < n:
339
+ if input_text[i] != _ESC:
340
+ i += 1
341
+ continue
342
+
343
+ # A control sequence begins; flush any pending text before it.
344
+ flush_text(i)
345
+
346
+ if i + 1 >= n or input_text[i + 1] != "[":
347
+ # A bare escape or a non-CSI escape (e.g. ESC followed by a single
348
+ # byte). Skip the escape and its immediate selector byte if present.
349
+ i += 1 if i + 1 >= n else 2
350
+ text_start = i
351
+ continue
352
+
353
+ # Consume the parameter/intermediate bytes up to the final byte.
354
+ j = i + 2
355
+ while j < n and not _is_final_byte(input_text[j]):
356
+ j += 1
357
+ final_byte = input_text[j] if j < n else None
358
+ body = input_text[i + 2 : j]
359
+
360
+ if final_byte == "m":
361
+ tokens.append(SgrCommandToken(params=_parse_params(body)))
362
+ # Any other final byte (or an unterminated sequence) is a non-SGR
363
+ # command and is dropped along with its parameters.
364
+
365
+ i = j + 1
366
+ text_start = i
367
+
368
+ flush_text(n)
369
+ return tokens
370
+
371
+
372
+ def _is_final_byte(ch: str) -> bool:
373
+ """A CSI sequence's final byte is in the range ``@``–``~`` (0x40–0x7e);
374
+ the bytes before it are parameters (``0``–``?``) and intermediates. This
375
+ predicate marks the end of the sequence's body during the scan.
376
+ """
377
+ code = ord(ch)
378
+ return 0x40 <= code <= 0x7E
379
+
380
+
381
+ #: Numeric prefix of one CSI parameter field (the TS port used ``parseInt``,
382
+ #: which reads an optional sign and the leading digits, ignoring the rest).
383
+ _PARAM_PREFIX = re.compile(r"\s*([+-]?\d+)")
384
+
385
+
386
+ def _parse_params(body: str) -> tuple[int, ...]:
387
+ """Parse a CSI parameter body (the bytes between ``ESC[`` and the final
388
+ ``m``) into a numeric list. Parameters are separated by ``;``; an empty
389
+ field is the standard's "default" parameter, which for SGR means ``0``,
390
+ so it is read as zero. Stray non-numeric characters in a field are
391
+ ignored, leaving the field's numeric prefix.
392
+ """
393
+ if len(body) == 0:
394
+ return ()
395
+
396
+ def field_value(field: str) -> int:
397
+ m = _PARAM_PREFIX.match(field)
398
+ return int(m.group(1)) if m is not None else 0
399
+
400
+ return tuple(field_value(field) for field in body.split(";"))
401
+
402
+
403
+ # ---------------------------------------------------------------------------
404
+ # Rendering
405
+ # ---------------------------------------------------------------------------
406
+
407
+ #: Map of each HTML-significant character to its text-safe entity.
408
+ _HTML_ESCAPES: Final[Mapping[str, str]] = MappingProxyType(
409
+ {
410
+ "&": "&amp;",
411
+ "<": "&lt;",
412
+ ">": "&gt;",
413
+ '"': "&quot;",
414
+ "'": "&#39;",
415
+ }
416
+ )
417
+
418
+
419
+ def _escape_html(text: str) -> str:
420
+ """Escape a text run for safe inclusion as HTML element content/attribute
421
+ text."""
422
+ out: list[str] = []
423
+ for ch in text:
424
+ out.append(_HTML_ESCAPES.get(ch, ch))
425
+ return "".join(out)
426
+
427
+
428
+ #: The CSS class prefix for every emitted attribute class, keeping the output
429
+ #: namespaced.
430
+ _CLASS_PREFIX: Final[str] = "sgr"
431
+
432
+ #: The boolean attributes of an :class:`SgrState` that map to a
433
+ #: presentational CSS class, paired with the class suffix each contributes.
434
+ #: ``inverse`` and ``hidden`` are handled separately (they reshape the colors
435
+ #: / visibility) and so are not in this list.
436
+ _FLAG_CLASSES: Final[tuple[tuple[str, str], ...]] = (
437
+ ("bold", "bold"),
438
+ ("dim", "dim"),
439
+ ("italic", "italic"),
440
+ ("underline", "underline"),
441
+ ("blink", "blink"),
442
+ ("strike", "strike"),
443
+ )
444
+
445
+
446
+ def _classes_for(state: SgrState) -> list[str]:
447
+ """Collect the presentational CSS classes a state contributes, in
448
+ declaration order. ``inverse`` adds a marker class as well, so a
449
+ stylesheet can react to the swap if it wants to, even though the swap is
450
+ also applied to the colors.
451
+ """
452
+ classes: list[str] = []
453
+ for key, suffix in _FLAG_CLASSES:
454
+ if getattr(state, key):
455
+ classes.append(f"{_CLASS_PREFIX}-{suffix}")
456
+ if state.inverse:
457
+ classes.append(f"{_CLASS_PREFIX}-inverse")
458
+ if state.hidden:
459
+ classes.append(f"{_CLASS_PREFIX}-hidden")
460
+ return classes
461
+
462
+
463
+ def _styles_for(state: SgrState) -> list[str]:
464
+ """Build the inline ``style`` declarations for a state's colors, resolving
465
+ the ``inverse`` swap and the ``hidden`` conceal at render time:
466
+
467
+ - under ``inverse``, foreground and background trade places (with the
468
+ default ink/surface filled in so the swap is visible even when a channel
469
+ was the default);
470
+ - under ``hidden``, the foreground is forced to match the background so
471
+ the text is concealed while still occupying its space.
472
+ """
473
+ fg = state.fg
474
+ bg = state.bg
475
+
476
+ if state.inverse:
477
+ swapped = fg
478
+ fg = bg if bg is not None else "var(--sgr-default-bg, #13161c)"
479
+ bg = swapped if swapped is not None else "var(--sgr-default-fg, #e6e8ee)"
480
+ if state.hidden:
481
+ fg = bg if bg is not None else "transparent"
482
+
483
+ decls: list[str] = []
484
+ if fg is not None:
485
+ decls.append(f"color:{fg}")
486
+ if bg is not None:
487
+ decls.append(f"background-color:{bg}")
488
+ return decls
489
+
490
+
491
+ def _is_plain(state: SgrState) -> bool:
492
+ """True when a state carries no style at all — its text needs no wrapping
493
+ span."""
494
+ return (
495
+ state.fg is None
496
+ and state.bg is None
497
+ and not state.bold
498
+ and not state.dim
499
+ and not state.italic
500
+ and not state.underline
501
+ and not state.blink
502
+ and not state.inverse
503
+ and not state.hidden
504
+ and not state.strike
505
+ )
506
+
507
+
508
+ def _render_run(text: str, state: SgrState) -> str:
509
+ """Wrap one escaped text run in a ``<span>`` carrying the state's classes
510
+ and inline color style. A run under the neutral state is emitted bare (no
511
+ span), so plain output stays plain HTML.
512
+ """
513
+ safe = _escape_html(text)
514
+ if _is_plain(state):
515
+ return safe
516
+
517
+ classes = _classes_for(state)
518
+ styles = _styles_for(state)
519
+
520
+ attrs: list[str] = []
521
+ if len(classes) > 0:
522
+ attrs.append(f'class="{" ".join(classes)}"')
523
+ if len(styles) > 0:
524
+ attrs.append(f'style="{";".join(styles)}"')
525
+
526
+ attr_text = " " + " ".join(attrs) if len(attrs) > 0 else ""
527
+ return f"<span{attr_text}>{safe}</span>"
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Public entry
532
+ # ---------------------------------------------------------------------------
533
+
534
+
535
+ def paint_sgr(input_text: str) -> str:
536
+ """Paint an ANSI/terminal byte stream into a string of styled HTML spans.
537
+
538
+ Drives the three stages end to end: tokenize the stream, fold each SGR
539
+ sequence into the running :class:`SgrState`, and wrap each intervening
540
+ text run in a span reflecting the state in force when that run was
541
+ emitted. Text is HTML-escaped; non-SGR control sequences are dropped; runs
542
+ under the neutral state are emitted without a wrapping span. The result is
543
+ safe to splice directly into a transcript page.
544
+
545
+ The state resets to :data:`SGR_INITIAL_STATE` only on an explicit reset
546
+ code (or a bare ``ESC[m``); it otherwise persists across text runs, so a
547
+ color set once stays in force until changed, exactly as a terminal renders
548
+ it.
549
+
550
+ :param input_text: the raw ANSI/terminal byte stream to convert
551
+ :returns: an HTML fragment of escaped text and styled ``<span>`` runs
552
+ """
553
+ if len(input_text) == 0:
554
+ return ""
555
+
556
+ tokens = tokenize_sgr(input_text)
557
+ state = replace(SGR_INITIAL_STATE)
558
+ out: list[str] = []
559
+
560
+ for token in tokens:
561
+ if token.kind == "sgr":
562
+ state = fold_sgr(state, token.params)
563
+ else:
564
+ out.append(_render_run(token.text, state))
565
+
566
+ return "".join(out)