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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- 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
|
+
"&": "&",
|
|
411
|
+
"<": "<",
|
|
412
|
+
">": ">",
|
|
413
|
+
'"': """,
|
|
414
|
+
"'": "'",
|
|
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)
|