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,188 @@
1
+ """Banner colour-sweep helpers — the pure, render-agnostic maths behind the
2
+ optional startup flourish and the personalized welcome line.
3
+
4
+ Port of TS ``src/console/components/banner-sweep.ts``. Two concerns, no
5
+ Textual or Rich, no I/O, no state:
6
+
7
+ - :func:`row_gradient` computes a *frozen* (single-pass, no animation timer)
8
+ colour for one wordmark row by lerping between two palette hexes across the
9
+ block's rows. The banner paints row ``i`` of ``n`` with this colour when the
10
+ opt-in sweep is on; off, the wordmark stays a flat accent. Because it takes
11
+ no clock and never schedules a frame, it is reduced-motion safe by
12
+ construction — the suppression decision lives at the call site, which simply
13
+ does not ask for a gradient when motion is reduced.
14
+
15
+ - :func:`welcome_line` formats the ``"Welcome back{, name}!"`` greeting from
16
+ the signed-in account label, with a length guard and a name-less fallback,
17
+ so the banner and its tests share one formatter rather than re-deriving the
18
+ string.
19
+
20
+ Everything here is referentially transparent: same inputs → same output.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass
27
+ from typing import Final
28
+
29
+ __all__ = ["row_gradient", "welcome_line"]
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Welcome line
34
+ # ---------------------------------------------------------------------------
35
+
36
+ #: The greeting shown when no signed-in account label is available.
37
+ _WELCOME_FALLBACK: Final[str] = "Welcome back!"
38
+
39
+ #: The longest account label the welcome line embeds before it is dropped.
40
+ #:
41
+ #: An account key can be an arbitrary string (an email, a display name, a
42
+ #: pasted token); past this width it stops reading as a name and starts
43
+ #: wrapping the banner, so an over-long label is treated as "no usable name"
44
+ #: and the fallback greeting is shown instead.
45
+ _MAX_NAME: Final[int] = 40
46
+
47
+
48
+ def welcome_line(name: str | None = None) -> str:
49
+ """Format the personalized welcome greeting.
50
+
51
+ Returns ``"Welcome back, {name}!"`` for a usable name, and the plain
52
+ ``"Welcome back!"`` fallback when the name is absent, blank, or too long
53
+ to read as a name. The name is trimmed of surrounding whitespace before
54
+ the checks so a padded label does not slip past the guards.
55
+
56
+ :param name: the signed-in account label, if one is known
57
+ :returns: the greeting string (plain text — never markup)
58
+ """
59
+ if name is None:
60
+ return _WELCOME_FALLBACK
61
+ trimmed = name.strip()
62
+ if len(trimmed) == 0 or len(trimmed) > _MAX_NAME:
63
+ return _WELCOME_FALLBACK
64
+ return f"Welcome back, {trimmed}!"
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Hex colour maths
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class _Rgb:
74
+ """One parsed colour channel triple, each component in the 0–255 range."""
75
+
76
+ r: int
77
+ g: int
78
+ b: int
79
+
80
+
81
+ _SIX_HEX_RE: Final[re.Pattern[str]] = re.compile(r"^[0-9a-fA-F]{6}$")
82
+
83
+
84
+ def _parse_hex(hex_color: str) -> _Rgb | None:
85
+ """Parse a ``#rrggbb`` (or ``#rgb``) hex string into its channel triple.
86
+
87
+ Tolerant of a missing leading ``#`` and of the short three-digit form
88
+ (each nibble is doubled). Returns ``None`` for anything it cannot read as
89
+ a colour, so the caller can fall back to the raw hex rather than emit
90
+ garbage.
91
+ """
92
+ body = hex_color[1:] if hex_color.startswith("#") else hex_color
93
+ if len(body) == 3:
94
+ six = "".join(ch + ch for ch in body)
95
+ elif len(body) == 6:
96
+ six = body
97
+ else:
98
+ return None
99
+ if _SIX_HEX_RE.match(six) is None:
100
+ return None
101
+ return _Rgb(
102
+ r=int(six[0:2], 16),
103
+ g=int(six[2:4], 16),
104
+ b=int(six[4:6], 16),
105
+ )
106
+
107
+
108
+ def _clamp_channel(value: float) -> int:
109
+ """Clamp to the byte range and round so a lerp result is a legal channel.
110
+
111
+ Rounding is JS ``Math.round`` (half away from zero on the positive axis,
112
+ i.e. half-up) rather than Python's banker's rounding, so the produced
113
+ hexes match the TS build digit for digit.
114
+ """
115
+ if value < 0:
116
+ return 0
117
+ if value > 255:
118
+ return 255
119
+ return int(value + 0.5)
120
+
121
+
122
+ def _lerp_rgb(start: _Rgb, end: _Rgb, t: float) -> _Rgb:
123
+ """Linear-interpolate between two channel triples by ``t`` in ``[0, 1]``.
124
+
125
+ ``t = 0`` returns ``start``, ``t = 1`` returns ``end``, anything between
126
+ mixes the two channel-wise. ``t`` is clamped so an out-of-range fraction
127
+ cannot overshoot.
128
+ """
129
+ k = 0.0 if t < 0 else 1.0 if t > 1 else t
130
+ return _Rgb(
131
+ r=_clamp_channel(start.r + (end.r - start.r) * k),
132
+ g=_clamp_channel(start.g + (end.g - start.g) * k),
133
+ b=_clamp_channel(start.b + (end.b - start.b) * k),
134
+ )
135
+
136
+
137
+ def _channel_hex(value: int) -> str:
138
+ """Render one byte channel as a two-digit lowercase hex pair."""
139
+ return format(_clamp_channel(value), "02x")
140
+
141
+
142
+ def _to_hex(rgb: _Rgb) -> str:
143
+ """Render a channel triple as a ``#rrggbb`` hex string (the form Rich's
144
+ colour parser accepts)."""
145
+ return f"#{_channel_hex(rgb.r)}{_channel_hex(rgb.g)}{_channel_hex(rgb.b)}"
146
+
147
+
148
+ def row_gradient(
149
+ row_index: int,
150
+ row_count: int,
151
+ primary_hex: str,
152
+ secondary_hex: str,
153
+ ) -> str:
154
+ """The frozen sweep colour for one wordmark row.
155
+
156
+ Lerps from ``primary_hex`` (the first row) to ``secondary_hex`` (the last
157
+ row) across the ``row_count`` rows of the block, returning the colour for
158
+ row ``row_index`` as a ``#rrggbb`` hex string. A single-row block pins to
159
+ the primary; the endpoints are exact (row 0 = primary, row n-1 =
160
+ secondary). If either hex cannot be parsed, the corresponding raw input
161
+ hex is returned so the banner still gets a usable colour string rather
162
+ than nothing.
163
+
164
+ This computes a *static* gradient — there is no timer, no frame loop, no
165
+ clock. The whole sweep is laid down in the one render pass, which is
166
+ exactly why it is safe under reduced-motion: the flourish is colour, not
167
+ movement.
168
+
169
+ :param row_index: the zero-based row being painted
170
+ :param row_count: the total number of rows in the wordmark block
171
+ :param primary_hex: the colour of the first row (e.g. the accent)
172
+ :param secondary_hex: the colour of the last row (e.g. the secondary
173
+ accent)
174
+ :returns: a ``#rrggbb`` hex string (or a raw input hex when one is
175
+ unparseable)
176
+ """
177
+ start = _parse_hex(primary_hex)
178
+ end = _parse_hex(secondary_hex)
179
+ if start is None:
180
+ return secondary_hex
181
+ if end is None:
182
+ return primary_hex
183
+ if row_index <= 0 or row_count <= 1:
184
+ return _to_hex(start)
185
+ if row_index >= row_count - 1:
186
+ return _to_hex(end)
187
+ t = row_index / (row_count - 1)
188
+ return _to_hex(_lerp_rgb(start, end, t))
@@ -0,0 +1,181 @@
1
+ """Emblem — the two-tone block monogram that sits to the left of the wordmark.
2
+
3
+ Port of TS ``src/console/components/Emblem.tsx``. Before this, the masthead
4
+ was lettering alone — the "INDUS CODE" wordmark *was* the whole identity,
5
+ painted in one flat accent. The emblem gives the brand a *mark*: a small
6
+ fixed-width block glyph built from the box quadrant family
7
+ (``▛ ▜ ▙ ▟ ▝ ▘ ▗ ▖ █ ▓``), split per row into a bright "fill" span and a dim
8
+ "shadow" span so it reads as a single solid shape lit from one corner. The
9
+ fill is the primary accent; the shadow is the secondary accent — the two
10
+ distinct palette hues the scheme already carries — so the mark is genuinely
11
+ two-tone rather than a single colour with a darker edge.
12
+
13
+ It is purely presentational: a fixed glyph grid tinted through the framework
14
+ :class:`~indusagi.react_ink.ThemeAdapter` at render time, no state beyond the
15
+ reactive inputs, no side effects, no props beyond the theme and an optional
16
+ row-colour override the banner uses to fold the emblem's fill into a frozen
17
+ colour-sweep.
18
+
19
+ Port shape (analysis 02 §7): the Ink ``Box``-per-row column becomes one
20
+ :class:`~textual.widgets.Static` whose ``render()`` returns a prebuilt
21
+ :class:`rich.text.Text`; the per-row fill/shadow ``<Text>`` spans become Rich
22
+ style spans on that one renderable (``chalk.hex`` → ``rich.style.Style
23
+ (color=…)``). The pure builders are exported so the banner can lay the emblem
24
+ beside the wordmark row-by-row without mounting this widget.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from dataclasses import dataclass
30
+ from typing import TYPE_CHECKING, Final, Sequence
31
+
32
+ from rich.color import ColorParseError
33
+ from rich.console import RenderableType
34
+ from rich.style import Style
35
+ from rich.text import Text
36
+ from textual.reactive import reactive
37
+ from textual.widgets import Static
38
+
39
+ if TYPE_CHECKING:
40
+ from indusagi.react_ink import InkThemeAdapter
41
+
42
+ __all__ = [
43
+ "EMBLEM_HEIGHT",
44
+ "Emblem",
45
+ "build_emblem_text",
46
+ "emblem_row_texts",
47
+ "paint_hex",
48
+ ]
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class _EmblemRow:
53
+ """One emblem row, split into the lit "fill" portion and the shadowed
54
+ portion.
55
+
56
+ Every row carries the same total glyph width so the column is
57
+ rectangular; either span may be empty when a row is wholly lit or wholly
58
+ in shadow.
59
+ """
60
+
61
+ # The brightly-lit portion of the row, painted in the primary accent.
62
+ fill: str
63
+ # The shadowed portion of the row, painted in the secondary accent.
64
+ shadow: str
65
+
66
+
67
+ #: The emblem glyph grid: a six-row block monogram from the box quadrant
68
+ #: family.
69
+ #:
70
+ #: Read top-to-bottom it is a solid rounded block with a lit upper-left face
71
+ #: and a shadowed lower-right face — the fill/shadow split per row is what
72
+ #: gives the two-tone, lit-from-a-corner look. Five glyphs wide on every row
73
+ #: so the column is rectangular and aligns with the wordmark beside it.
74
+ _EMBLEM_ROWS: Final[tuple[_EmblemRow, ...]] = (
75
+ _EmblemRow(fill="▛▀▀▜", shadow="▖"),
76
+ _EmblemRow(fill="▌█▓", shadow="▝▌"),
77
+ _EmblemRow(fill="▌█▓", shadow=" ▌"),
78
+ _EmblemRow(fill="▌█▓", shadow="▗▌"),
79
+ _EmblemRow(fill="▌█", shadow="▓▟▘"),
80
+ _EmblemRow(fill="▙▄", shadow="▄▟▘"),
81
+ )
82
+
83
+ #: How many terminal rows the emblem occupies (its glyph-grid height).
84
+ EMBLEM_HEIGHT: Final[int] = len(_EMBLEM_ROWS)
85
+
86
+
87
+ def paint_hex(hex_color: str, text: str) -> Text:
88
+ """Paint ``text`` in a raw ``#rrggbb`` hex, falling back to the unpainted
89
+ text if Rich rejects the colour — used for the optional colour-sweep,
90
+ whose per-row hexes do not exist as theme colour-keys.
91
+
92
+ The TS source wrapped ``chalk.hex`` in a try/catch; Rich parses colours
93
+ eagerly in ``Style.__init__`` and raises :class:`ColorParseError` for
94
+ garbage, which degrades to unstyled text exactly like chalk did.
95
+ """
96
+ try:
97
+ return Text(text, style=Style(color=hex_color))
98
+ except ColorParseError:
99
+ return Text(text)
100
+
101
+
102
+ def emblem_row_texts(
103
+ theme: InkThemeAdapter,
104
+ row_colors: Sequence[str] | None = None,
105
+ ) -> tuple[Text, ...]:
106
+ """The emblem as one prebuilt :class:`rich.text.Text` per glyph row.
107
+
108
+ Each row paints its fill span in the accent role (or the supplied sweep
109
+ colour for that row) and its shadow span in the secondary
110
+ ``customMessage`` role, so the monogram reads as one solid mark with a
111
+ lit and a shadowed face. The banner consumes these to lay the emblem
112
+ beside the wordmark row-by-row.
113
+
114
+ :param theme: the framework adapter that turns roles into colours
115
+ :param row_colors: optional per-row fill colour (a ``#rrggbb`` hex),
116
+ indexed by row — the banner's frozen colour-sweep. Absent rows fall
117
+ back to the accent role; the shadow keeps the secondary accent so
118
+ the mark stays two-tone.
119
+ """
120
+ rows: list[Text] = []
121
+ for index, row in enumerate(_EMBLEM_ROWS):
122
+ sweep = row_colors[index] if row_colors is not None and index < len(row_colors) else None
123
+ fill = paint_hex(sweep, row.fill) if sweep is not None else theme.color("accent", row.fill)
124
+ line = Text()
125
+ line.append_text(fill)
126
+ line.append_text(theme.color("customMessage", row.shadow))
127
+ rows.append(line)
128
+ return tuple(rows)
129
+
130
+
131
+ def build_emblem_text(
132
+ theme: InkThemeAdapter,
133
+ row_colors: Sequence[str] | None = None,
134
+ ) -> Text:
135
+ """The whole two-tone emblem as a single prebuilt Rich ``Text``.
136
+
137
+ The Ink column-of-rows becomes the rows of :func:`emblem_row_texts`
138
+ joined by newlines — what the :class:`Emblem` widget renders.
139
+ """
140
+ return Text("\n").join(emblem_row_texts(theme, row_colors))
141
+
142
+
143
+ class Emblem(Static):
144
+ """The two-tone block emblem as one persistent ``Static``.
145
+
146
+ ``theme`` and ``row_colors`` are reactive, so a scheme switch (or the
147
+ banner toggling the sweep) re-renders the mark in place; the glyph grid
148
+ itself never changes.
149
+ """
150
+
151
+ DEFAULT_CSS = """
152
+ Emblem {
153
+ width: auto;
154
+ height: auto;
155
+ }
156
+ """
157
+
158
+ theme: reactive[InkThemeAdapter | None] = reactive(None, layout=True)
159
+ row_colors: reactive[tuple[str, ...] | None] = reactive(None, layout=True)
160
+
161
+ def __init__(
162
+ self,
163
+ theme: InkThemeAdapter,
164
+ *,
165
+ row_colors: Sequence[str] | None = None,
166
+ name: str | None = None,
167
+ id: str | None = None,
168
+ classes: str | None = None,
169
+ disabled: bool = False,
170
+ ) -> None:
171
+ super().__init__("", name=name, id=id, classes=classes, disabled=disabled)
172
+ self.set_reactive(Emblem.theme, theme)
173
+ self.set_reactive(
174
+ Emblem.row_colors, tuple(row_colors) if row_colors is not None else None
175
+ )
176
+
177
+ def render(self) -> RenderableType:
178
+ theme = self.theme
179
+ if theme is None:
180
+ return Text("")
181
+ return build_emblem_text(theme, self.row_colors)
@@ -0,0 +1,102 @@
1
+ """StatusBar — the bottom chrome strip of the console.
2
+
3
+ Port of TS ``src/console/components/StatusBar.tsx``. Composes the framework's
4
+ :class:`~indusagi.react_ink.StatusLine` (the transient toast row) above its
5
+ :class:`~indusagi.react_ink.Footer` (the persistent session/usage strip),
6
+ both fed from a projected :class:`~indusagi.react_ink.SessionSnapshot`. The
7
+ console owns no rendering of its own here — it threads the resolved theme
8
+ adapter and the current snapshot/status into the framework widgets, which do
9
+ the layout. Purely presentational.
10
+
11
+ Port shape (analysis 02 §7 — "mostly collapses"): the Ink column ``<Box>``
12
+ becomes a :class:`~textual.containers.Vertical` holding the two framework
13
+ widgets, which the ``ConsoleApp`` docks at the bottom. Where TS re-rendered
14
+ with fresh props every frame, the Textual widgets are retained and reactive:
15
+ :meth:`StatusBar.update_state` threads one TS-shaped prop set (snapshot /
16
+ branch / provider count / status) into the children's reactives exactly as
17
+ the framework expects (``StatusLine.snapshot``/``.status``;
18
+ ``Footer.snapshot``/``.branch``/``.available_provider_count``).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING
24
+
25
+ from textual.containers import Vertical
26
+
27
+ from indusagi.react_ink import Footer, StatusLine
28
+
29
+ if TYPE_CHECKING:
30
+ from indusagi.react_ink import InkThemeAdapter, SessionSnapshot, StatusMessage
31
+
32
+ __all__ = ["StatusBar"]
33
+
34
+
35
+ class StatusBar(Vertical):
36
+ """The toast row above the footer strip.
37
+
38
+ Constructor inputs mirror the TS props (``theme`` / ``snapshot`` /
39
+ ``branch`` / ``provider_count`` / ``status``); the children are created
40
+ eagerly and exposed as :attr:`status_line` / :attr:`footer` so the host
41
+ app can also reach them directly. Both framework widgets hide themselves
42
+ until they have something to show, so an empty bar takes no rows.
43
+ """
44
+
45
+ DEFAULT_CSS = """
46
+ StatusBar {
47
+ width: 1fr;
48
+ height: auto;
49
+ }
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ theme: InkThemeAdapter,
55
+ *,
56
+ snapshot: SessionSnapshot | None = None,
57
+ branch: str | None = None,
58
+ provider_count: int = 0,
59
+ status: StatusMessage | None = None,
60
+ name: str | None = None,
61
+ id: str | None = None,
62
+ classes: str | None = None,
63
+ disabled: bool = False,
64
+ ) -> None:
65
+ # The framework strips, threaded exactly the props the TS StatusBar
66
+ # forwarded: StatusLine gets snapshot + status, Footer gets snapshot
67
+ # + branch + availableProviderCount.
68
+ self.status_line = StatusLine(theme, snapshot=snapshot, status=status)
69
+ self.footer = Footer(
70
+ theme,
71
+ snapshot=snapshot,
72
+ branch=branch,
73
+ available_provider_count=provider_count,
74
+ )
75
+ super().__init__(
76
+ self.status_line,
77
+ self.footer,
78
+ name=name,
79
+ id=id,
80
+ classes=classes,
81
+ disabled=disabled,
82
+ )
83
+
84
+ def update_state(
85
+ self,
86
+ snapshot: SessionSnapshot | None,
87
+ *,
88
+ branch: str | None = None,
89
+ provider_count: int = 0,
90
+ status: StatusMessage | None = None,
91
+ ) -> None:
92
+ """Thread a fresh TS-shaped prop set into the framework widgets.
93
+
94
+ The console calls this once per projected snapshot (an agent event,
95
+ a toast change, a branch move); the children's reactives re-render
96
+ the strips in place.
97
+ """
98
+ self.status_line.snapshot = snapshot
99
+ self.status_line.status = status
100
+ self.footer.snapshot = snapshot
101
+ self.footer.branch = branch
102
+ self.footer.available_provider_count = provider_count