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,62 @@
1
+ """Console chrome widgets (M5 wave 2) — the masthead and bottom strip.
2
+
3
+ Port of TS ``src/console/components`` (minus the root surface, which lands
4
+ with the wave-3 ``ConsoleApp``). Everything here is purely presentational:
5
+ Static/container widgets rendering prebuilt Rich ``Text`` through the
6
+ framework :class:`~indusagi.react_ink.ThemeAdapter`, plus the pure
7
+ colour-sweep / welcome-line maths they share with the tests.
8
+
9
+ - :mod:`.banner` — :class:`Banner`, the masthead: block-figlet "INDUS CODE"
10
+ wordmark beside the two-tone emblem, brand/version + welcome lines, the
11
+ bordered Session and Startup Map panels, the changelog block (rendered via
12
+ the framework's ``build_changelog_text``), quiet/compact condensed modes,
13
+ and the optional *frozen* colour-sweep (static gradient — no animation
14
+ loop, reduced-motion safe by construction).
15
+ - :mod:`.emblem` — :class:`Emblem`, the two-tone block monogram (box-quadrant
16
+ fill/shadow rows; ``chalk.hex`` spans become ``rich.style.Style`` colours).
17
+ - :mod:`.banner_sweep` — the pure helpers: :func:`welcome_line` and the
18
+ :func:`row_gradient` hex-lerp, ported verbatim.
19
+ - :mod:`.status_bar` — :class:`StatusBar`, the thin Vertical composing the
20
+ framework ``StatusLine`` + ``Footer`` strips.
21
+
22
+ Deleted on port — ``Composer.tsx`` (analysis 02 §7): the TS prompt row with
23
+ its software caret and inline suggestion window is NOT ported. The framework
24
+ ``PromptEditor`` (with the console's ``input`` autocomplete providers feeding
25
+ its suggestion overlay) replaces the whole file; the wave-3 ``ConsoleApp``
26
+ wires it. Nothing in this package renders an input row.
27
+ """
28
+
29
+ from .banner import (
30
+ BRAND,
31
+ WORDMARK_ROWS,
32
+ Banner,
33
+ build_banner_text,
34
+ emblem_sweep_colors,
35
+ wordmark_lines,
36
+ )
37
+ from .banner_sweep import row_gradient, welcome_line
38
+ from .emblem import (
39
+ EMBLEM_HEIGHT,
40
+ Emblem,
41
+ build_emblem_text,
42
+ emblem_row_texts,
43
+ paint_hex,
44
+ )
45
+ from .status_bar import StatusBar
46
+
47
+ __all__ = [
48
+ "BRAND",
49
+ "Banner",
50
+ "EMBLEM_HEIGHT",
51
+ "Emblem",
52
+ "StatusBar",
53
+ "WORDMARK_ROWS",
54
+ "build_banner_text",
55
+ "build_emblem_text",
56
+ "emblem_row_texts",
57
+ "emblem_sweep_colors",
58
+ "paint_hex",
59
+ "row_gradient",
60
+ "welcome_line",
61
+ "wordmark_lines",
62
+ ]
@@ -0,0 +1,499 @@
1
+ """Banner — the masthead the console renders above the transcript.
2
+
3
+ Port of TS ``src/console/components/Banner.tsx``. The startup chrome the
4
+ surface mounts once at the top of a session: an original block-letter
5
+ wordmark rendered in the box-drawing palette and tinted with the accent
6
+ role, a version + brand line beneath it, the personalized welcome line, a
7
+ compact bordered "Session" panel of at-a-glance facts (the bound model id and
8
+ the working directory), the bordered "Startup Map" of gathered resources,
9
+ and the "What is new" changelog block on a version bump. It reads nothing
10
+ but its inputs, holds no state beyond them, and runs no effects — purely
11
+ presentational, themed through the framework
12
+ :class:`~indusagi.react_ink.ThemeAdapter`.
13
+
14
+ The wordmark is the "INDUS CODE" brand masthead rendered in the ANSI-Shadow
15
+ block-figlet style from the ``█ ║ ═ ╔ ╗ ╚ ╝`` box-drawing family — the same
16
+ masthead the shipped console shows, so both surfaces share one identity. The
17
+ glyph rows are plain data tinted with the accent role at render time.
18
+
19
+ Port shape (analysis 02 §7): the Ink component tree (``Wordmark`` /
20
+ ``WelcomeLine`` / ``SessionFact`` / ``StartupNotices`` / ``StartupMapPanel``
21
+ / ``StartupChangelogBlock``) becomes pure builders assembling one
22
+ :class:`rich.text.Text`, rendered by a single :class:`~textual.widgets
23
+ .Static`. Ink's round-bordered boxes become hand-drawn ``╭─╮│╰─╯`` frames in
24
+ the accent tone; the changelog block reuses the framework's
25
+ ``build_changelog_text`` (the exact renderer behind the framework
26
+ ``ChangelogBlock`` widget); ``chalk.hex`` becomes ``rich.style.Style
27
+ (color=…)`` via :func:`~.emblem.paint_hex`.
28
+
29
+ The optional colour-sweep is STATIC, exactly as in TS: :func:`row_gradient`
30
+ takes no clock and the gradient is laid down in the one render pass — no
31
+ animation loop exists to suppress, which is what makes it reduced-motion
32
+ safe. The caller resolves the on/off decision (reduced-motion / non-TTY);
33
+ ``sweep`` here is simply that resolved flag.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ from typing import TYPE_CHECKING, Final, Literal, Sequence
39
+
40
+ from rich.cells import cell_len
41
+ from rich.console import RenderableType
42
+ from rich.text import Text
43
+ from textual.reactive import reactive
44
+ from textual.widgets import Static
45
+
46
+ from indusagi.react_ink import build_changelog_text
47
+
48
+ from ..startup import StartupChangelog, StartupMap, StartupNotice
49
+ from .banner_sweep import row_gradient, welcome_line
50
+ from .emblem import EMBLEM_HEIGHT, emblem_row_texts, paint_hex
51
+
52
+ if TYPE_CHECKING:
53
+ from indusagi.react_ink import InkThemeAdapter
54
+
55
+ __all__ = [
56
+ "BRAND",
57
+ "Banner",
58
+ "WORDMARK_ROWS",
59
+ "build_banner_text",
60
+ "emblem_sweep_colors",
61
+ "wordmark_lines",
62
+ ]
63
+
64
+
65
+ #: The human-readable product label shown on the version line.
66
+ #:
67
+ #: parity: kept verbatim from the TS Banner, which hardcodes its own masthead
68
+ #: label rather than reading the workspace brand record.
69
+ BRAND: Final[str] = "indus console"
70
+
71
+ #: The compact emblem glyph drawn inline on the condensed one-liner header.
72
+ _COMPACT_EMBLEM: Final[str] = "▛▜▙▟"
73
+
74
+ #: The block-letter brand wordmark, one string per terminal row.
75
+ #:
76
+ #: The product name "INDUS CODE" laid out in the ANSI-Shadow block-figlet
77
+ #: style from the ``█ ║ ═ ╔ ╗ ╚ ╝`` box-drawing family. Every row is the same
78
+ #: display width so the capitals stack into a clean masthead; the render
79
+ #: tints the whole block with the accent role.
80
+ WORDMARK_ROWS: Final[tuple[str, ...]] = (
81
+ "██╗███╗ ██╗██████╗ ██╗ ██╗███████╗ ██████╗ ██████╗ ██████╗ ███████╗",
82
+ "██║████╗ ██║██╔══██╗██║ ██║██╔════╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝",
83
+ "██║██╔██╗ ██║██║ ██║██║ ██║███████╗ ██║ ██║ ██║██║ ██║█████╗ ",
84
+ "██║██║╚██╗██║██║ ██║██║ ██║╚════██║ ██║ ██║ ██║██║ ██║██╔══╝ ",
85
+ "██║██║ ╚████║██████╔╝╚██████╔╝███████║ ╚██████╗╚██████╔╝██████╔╝███████╗",
86
+ "╚═╝╚═╝ ╚═══╝╚═════╝ ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
87
+ )
88
+
89
+ #: The sweep-endpoint fallbacks the TS source used when a scheme leaves the
90
+ #: accent / secondary-accent keys unset.
91
+ _PRIMARY_FALLBACK: Final[str] = "#3ad6b4"
92
+ _SECONDARY_FALLBACK: Final[str] = "#f0a24b"
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Sweep endpoints
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _sweep_endpoints(theme: InkThemeAdapter) -> tuple[str, str]:
101
+ """The primary→secondary hex pair the frozen gradient lerps between."""
102
+ primary = theme.colors.get("accent") or _PRIMARY_FALLBACK
103
+ secondary = theme.colors.get("customMessage") or theme.colors.get("accent") or _SECONDARY_FALLBACK
104
+ return primary, secondary
105
+
106
+
107
+ def emblem_sweep_colors(theme: InkThemeAdapter, sweep: bool) -> tuple[str, ...] | None:
108
+ """The emblem-glyph computed gradient row colours for the colour-sweep,
109
+ or ``None`` when the sweep is off (the emblem keeps its flat accent
110
+ fill).
111
+
112
+ The emblem is :data:`~.emblem.EMBLEM_HEIGHT` rows tall; each is coloured
113
+ over the same primary→secondary lerp so the mark and the wordmark share
114
+ one gradient.
115
+ """
116
+ if not sweep:
117
+ return None
118
+ primary, secondary = _sweep_endpoints(theme)
119
+ return tuple(
120
+ row_gradient(i, EMBLEM_HEIGHT, primary, secondary) for i in range(EMBLEM_HEIGHT)
121
+ )
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Pure piece builders (the TS sub-components, as Text producers)
126
+ # ---------------------------------------------------------------------------
127
+
128
+
129
+ def wordmark_lines(theme: InkThemeAdapter, sweep: bool = False) -> tuple[Text, ...]:
130
+ """The block-letter wordmark, optionally tinted with the frozen
131
+ colour-sweep — one ``Text`` per glyph row.
132
+
133
+ With ``sweep`` off (the default) every row is the flat accent role. With
134
+ it on, row ``i`` is painted with :func:`row_gradient`'s frozen lerp
135
+ between the accent (primary) and ``customMessage`` (secondary) palette
136
+ hues — a single render pass, no timer.
137
+ """
138
+ rows = len(WORDMARK_ROWS)
139
+ primary, secondary = _sweep_endpoints(theme)
140
+ out: list[Text] = []
141
+ for index, line in enumerate(WORDMARK_ROWS):
142
+ if sweep:
143
+ out.append(paint_hex(row_gradient(index, rows, primary, secondary), line))
144
+ else:
145
+ out.append(theme.color("accent", line))
146
+ return tuple(out)
147
+
148
+
149
+ def _notice_lines(theme: InkThemeAdapter, notices: Sequence[StartupNotice]) -> list[Text]:
150
+ """The notices region: one themed line per gathered startup notice, drawn
151
+ above the wordmark, plus the trailing blank (Ink ``marginBottom={1}``).
152
+ Empty when there are no notices."""
153
+ if len(notices) == 0:
154
+ return []
155
+ lines: list[Text] = []
156
+ for notice in notices:
157
+ if notice.kind == "error":
158
+ lines.append(theme.color("error", notice.text))
159
+ elif notice.kind == "warning":
160
+ lines.append(theme.color("warning", notice.text))
161
+ else:
162
+ lines.append(theme.muted(notice.text))
163
+ lines.append(Text(""))
164
+ return lines
165
+
166
+
167
+ def _welcome_text(
168
+ theme: InkThemeAdapter,
169
+ name: str | None,
170
+ tone: Literal["accent", "muted"] = "accent",
171
+ ) -> Text:
172
+ """The personalized welcome line, themed in the given tone with the name
173
+ in bold."""
174
+
175
+ def paint(text: str) -> Text:
176
+ return theme.muted(text) if tone == "muted" else theme.color("accent", text)
177
+
178
+ line = welcome_line(name)
179
+ # Split out the name so it can be bolded between the framing words; the
180
+ # formatter guarantees the "Welcome back, {name}!" / "Welcome back!"
181
+ # shapes.
182
+ if name is not None and line != "Welcome back!":
183
+ out = Text()
184
+ out.append_text(paint("Welcome back, "))
185
+ out.append(name.strip(), style="bold")
186
+ out.append_text(paint("!"))
187
+ return out
188
+ return paint(line)
189
+
190
+
191
+ def _space_between(left: Text, right: Text, width: int) -> Text:
192
+ """Lay ``left`` and ``right`` on one row with the slack between them —
193
+ Ink's ``justifyContent="space-between"`` for a fixed-width panel row."""
194
+ gap = max(1, width - left.cell_len - right.cell_len)
195
+ out = Text()
196
+ out.append_text(left)
197
+ out.append(" " * gap)
198
+ out.append_text(right)
199
+ return out
200
+
201
+
202
+ def _panel_lines(
203
+ theme: InkThemeAdapter,
204
+ content: Sequence[Text],
205
+ columns: int,
206
+ ) -> list[Text]:
207
+ """Frame ``content`` rows in a round accent border with one cell of
208
+ horizontal padding — Ink's ``borderStyle="round"`` ``borderColor=accent``
209
+ ``paddingX={1}`` box, drawn by hand into text rows."""
210
+ inner = max(10, columns - 4)
211
+ border = theme.color_style("accent")
212
+ lines: list[Text] = [Text("╭" + "─" * (inner + 2) + "╮", style=border)]
213
+ for row in content:
214
+ clipped = row.copy()
215
+ clipped.truncate(inner, overflow="ellipsis", pad=True)
216
+ line = Text()
217
+ line.append("│ ", style=border)
218
+ line.append_text(clipped)
219
+ line.append(" │", style=border)
220
+ lines.append(line)
221
+ lines.append(Text("╰" + "─" * (inner + 2) + "╯", style=border))
222
+ return lines
223
+
224
+
225
+ def _session_fact(theme: InkThemeAdapter, label: str, value: str) -> Text:
226
+ """One labelled row inside the Session panel: a dim caption and its
227
+ value."""
228
+ out = Text()
229
+ out.append_text(theme.dim(f"{label:<7} "))
230
+ out.append_text(theme.muted(value))
231
+ return out
232
+
233
+
234
+ def _session_panel_lines(
235
+ theme: InkThemeAdapter,
236
+ model_id: str,
237
+ workspace: str,
238
+ verbose: bool,
239
+ columns: int,
240
+ ) -> list[Text]:
241
+ """The bordered Session panel: the at-a-glance facts of the session."""
242
+ rows: list[Text] = [
243
+ theme.color("accent", "Session"),
244
+ _session_fact(theme, "model", model_id),
245
+ _session_fact(theme, "cwd", workspace),
246
+ ]
247
+ if verbose:
248
+ rows.append(_session_fact(theme, "mode", "verbose diagnostics on"))
249
+ return _panel_lines(theme, rows, columns)
250
+
251
+
252
+ def _startup_map_lines(
253
+ theme: InkThemeAdapter,
254
+ startup: StartupMap,
255
+ columns: int,
256
+ ) -> list[Text]:
257
+ """The Startup Map panel: a bordered list of the gathered session
258
+ resources, one titled group per section with its entry lines indented
259
+ beneath it. Empty when the map has no sections."""
260
+ if len(startup.sections) == 0:
261
+ return []
262
+ inner = max(10, columns - 4)
263
+ total = sum(section.count for section in startup.sections)
264
+ plural = "" if total == 1 else "s"
265
+ rows: list[Text] = [
266
+ _space_between(
267
+ theme.color("accent", "Startup Map"),
268
+ theme.dim(f"{total} item{plural}"),
269
+ inner,
270
+ )
271
+ ]
272
+ for section in startup.sections:
273
+ rows.append(Text("")) # Ink marginTop={1} per section group.
274
+ rows.append(
275
+ _space_between(theme.muted(section.title), theme.dim(str(section.count)), inner)
276
+ )
277
+ for line in section.lines:
278
+ entry = Text(" ") # Ink paddingLeft={2}.
279
+ entry.append_text(theme.dim("› "))
280
+ entry.append_text(theme.muted(line))
281
+ rows.append(entry)
282
+ return _panel_lines(theme, rows, columns)
283
+
284
+
285
+ def _changelog_lines(
286
+ theme: InkThemeAdapter,
287
+ changelog: StartupChangelog,
288
+ columns: int,
289
+ ) -> list[Text]:
290
+ """The changelog region. On a full survey it renders the framework
291
+ changelog block (the exact ``ChangelogBlock`` renderable); on a condensed
292
+ survey a single "updated to" line preceded by its ``marginTop`` blank;
293
+ otherwise nothing."""
294
+ if changelog.mode == "full" and changelog.markdown is not None:
295
+ return [build_changelog_text("What is new", changelog.markdown, theme, columns)]
296
+ if changelog.mode == "condensed":
297
+ line = Text()
298
+ line.append_text(theme.muted(f"Updated to v{changelog.version}. Use "))
299
+ line.append_text(theme.color("accent", "/changelog"))
300
+ line.append_text(theme.muted(" to view the full changelog."))
301
+ return [Text(""), line]
302
+ return []
303
+
304
+
305
+ # ---------------------------------------------------------------------------
306
+ # The assembled masthead
307
+ # ---------------------------------------------------------------------------
308
+
309
+
310
+ def build_banner_text(
311
+ theme: InkThemeAdapter,
312
+ *,
313
+ model_id: str,
314
+ workspace: str,
315
+ version: str,
316
+ verbose: bool = False,
317
+ quiet: bool = False,
318
+ compact: bool = False,
319
+ account: str | None = None,
320
+ sweep: bool = False,
321
+ startup: StartupMap | None = None,
322
+ notices: Sequence[StartupNotice] | None = None,
323
+ changelog: StartupChangelog | None = None,
324
+ columns: int = 80,
325
+ ) -> Text:
326
+ """Assemble the console masthead as one prebuilt Rich ``Text``.
327
+
328
+ In the default (loud) mode this is the two-tone emblem beside the
329
+ block-letter wordmark, the brand / version line, the personalized welcome
330
+ line, the optional notices region, the bordered Session and Startup Map
331
+ panels, and the changelog block. The ``quiet`` or ``compact`` flags
332
+ collapse all of that to a single compact header line (emblem glyph +
333
+ brand + version + model), still carrying the welcome line, the notices,
334
+ and the changelog so nothing important is silently dropped — ``compact``
335
+ keeps the inline emblem glyph (the repeat-launch presentation), ``quiet``
336
+ is the legacy strip.
337
+
338
+ :param theme: the framework adapter that turns roles into colours
339
+ :param model_id: canonical id of the model bound to the session
340
+ :param workspace: working directory the session is scoped to
341
+ :param version: the product version shown on the brand line
342
+ :param verbose: whether to render the extra diagnostics fact
343
+ :param quiet: suppress the big wordmark + panels (manual toggle)
344
+ :param compact: auto-condensed repeat-launch one-liner
345
+ :param account: the signed-in account label for the welcome line (the TS
346
+ ``name`` prop; renamed — ``name`` is the Textual widget identity)
347
+ :param sweep: the resolved static colour-sweep on/off decision
348
+ :param startup: the gathered session resources (the Startup Map panel)
349
+ :param notices: out-of-band lines drawn above the wordmark
350
+ :param changelog: the changelog survey shown on a version bump
351
+ :param columns: the laid-out width panels and dividers size against
352
+ """
353
+ notice_list = tuple(notices) if notices is not None else ()
354
+ lines: list[Text] = list(_notice_lines(theme, notice_list))
355
+
356
+ # The condensed one-liner: the manual quiet toggle *or* an auto-condensed
357
+ # repeat launch. Both collapse to the same compact header.
358
+ if quiet or compact:
359
+ header = Text()
360
+ if compact:
361
+ header.append_text(theme.color("accent", _COMPACT_EMBLEM[:2]))
362
+ header.append_text(theme.color("customMessage", _COMPACT_EMBLEM[2:]))
363
+ header.append(" ")
364
+ brand = theme.color("accent", BRAND)
365
+ brand.stylize("bold")
366
+ header.append_text(brand)
367
+ header.append(" ")
368
+ header.append_text(theme.muted(f"v{version}"))
369
+ header.append(" ")
370
+ header.append_text(theme.dim(model_id))
371
+ lines.append(header)
372
+ lines.append(_welcome_text(theme, account, tone="muted"))
373
+ if changelog is not None:
374
+ lines.extend(_changelog_lines(theme, changelog, columns))
375
+ return Text("\n").join(lines)
376
+
377
+ # Emblem beside wordmark: both blocks are the same height, so each
378
+ # terminal row is `emblem row + two spaces + wordmark row` (the Ink row
379
+ # Box of two columns).
380
+ emblem_rows = emblem_row_texts(theme, emblem_sweep_colors(theme, sweep))
381
+ wordmark_rows = wordmark_lines(theme, sweep)
382
+ for emblem_row, wordmark_row in zip(emblem_rows, wordmark_rows):
383
+ row = Text()
384
+ row.append_text(emblem_row)
385
+ row.append(" ")
386
+ row.append_text(wordmark_row)
387
+ lines.append(row)
388
+
389
+ # Brand / version line + welcome line (Ink marginTop={1} block).
390
+ lines.append(Text(""))
391
+ brand_line = Text()
392
+ brand = theme.color("accent", BRAND)
393
+ brand.stylize("bold")
394
+ brand_line.append_text(brand)
395
+ brand_line.append(" ")
396
+ brand_line.append_text(theme.muted(f"v{version}"))
397
+ lines.append(brand_line)
398
+ lines.append(_welcome_text(theme, account, tone="accent"))
399
+
400
+ # Session panel (marginTop={1}).
401
+ lines.append(Text(""))
402
+ lines.extend(_session_panel_lines(theme, model_id, workspace, verbose, columns))
403
+
404
+ # Startup Map panel (marginTop={1}; omitted when the map is empty).
405
+ if startup is not None and len(startup.sections) > 0:
406
+ lines.append(Text(""))
407
+ lines.extend(_startup_map_lines(theme, startup, columns))
408
+
409
+ if changelog is not None:
410
+ lines.extend(_changelog_lines(theme, changelog, columns))
411
+
412
+ return Text("\n").join(lines)
413
+
414
+
415
+ class Banner(Static):
416
+ """The console masthead as one persistent ``Static``.
417
+
418
+ All inputs are reactive so the surface can re-skin the banner on a scheme
419
+ switch (live theme preview) or late-arriving startup data; the widget
420
+ re-renders its prebuilt ``Text`` in place. The Ink root's
421
+ ``marginBottom={1}`` becomes widget margin.
422
+ """
423
+
424
+ DEFAULT_CSS = """
425
+ Banner {
426
+ width: 1fr;
427
+ height: auto;
428
+ }
429
+ """
430
+
431
+ theme: reactive[InkThemeAdapter | None] = reactive(None, layout=True)
432
+ model_id: reactive[str] = reactive("", layout=True)
433
+ workspace: reactive[str] = reactive("", layout=True)
434
+ version: reactive[str] = reactive("", layout=True)
435
+ verbose: reactive[bool] = reactive(False, layout=True)
436
+ quiet: reactive[bool] = reactive(False, layout=True)
437
+ compact: reactive[bool] = reactive(False, layout=True)
438
+ account: reactive[str | None] = reactive(None, layout=True)
439
+ sweep: reactive[bool] = reactive(False, layout=True)
440
+ startup: reactive[StartupMap | None] = reactive(None, layout=True)
441
+ notices: reactive[tuple[StartupNotice, ...]] = reactive((), layout=True)
442
+ changelog: reactive[StartupChangelog | None] = reactive(None, layout=True)
443
+
444
+ def __init__(
445
+ self,
446
+ theme: InkThemeAdapter,
447
+ *,
448
+ model_id: str,
449
+ workspace: str,
450
+ version: str,
451
+ verbose: bool = False,
452
+ quiet: bool = False,
453
+ compact: bool = False,
454
+ account: str | None = None,
455
+ sweep: bool = False,
456
+ startup: StartupMap | None = None,
457
+ notices: Sequence[StartupNotice] | None = None,
458
+ changelog: StartupChangelog | None = None,
459
+ name: str | None = None,
460
+ id: str | None = None,
461
+ classes: str | None = None,
462
+ disabled: bool = False,
463
+ ) -> None:
464
+ super().__init__("", name=name, id=id, classes=classes, disabled=disabled)
465
+ self.set_reactive(Banner.theme, theme)
466
+ self.set_reactive(Banner.model_id, model_id)
467
+ self.set_reactive(Banner.workspace, workspace)
468
+ self.set_reactive(Banner.version, version)
469
+ self.set_reactive(Banner.verbose, verbose)
470
+ self.set_reactive(Banner.quiet, quiet)
471
+ self.set_reactive(Banner.compact, compact)
472
+ self.set_reactive(Banner.account, account)
473
+ self.set_reactive(Banner.sweep, sweep)
474
+ self.set_reactive(Banner.startup, startup)
475
+ self.set_reactive(Banner.notices, tuple(notices) if notices is not None else ())
476
+ self.set_reactive(Banner.changelog, changelog)
477
+ # Ink: <Box flexDirection="column" marginBottom={1}>
478
+ self.styles.margin = (0, 0, 1, 0)
479
+
480
+ def render(self) -> RenderableType:
481
+ theme = self.theme
482
+ if theme is None:
483
+ return Text("")
484
+ columns = self.size.width or 80
485
+ return build_banner_text(
486
+ theme,
487
+ model_id=self.model_id,
488
+ workspace=self.workspace,
489
+ version=self.version,
490
+ verbose=self.verbose,
491
+ quiet=self.quiet,
492
+ compact=self.compact,
493
+ account=self.account,
494
+ sweep=self.sweep,
495
+ startup=self.startup,
496
+ notices=self.notices,
497
+ changelog=self.changelog,
498
+ columns=columns,
499
+ )