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,434 @@
1
+ """Startup gathering — the pure resource/notices/changelog reader the banner
2
+ draws.
3
+
4
+ Port of TS ``src/console/startup.ts``. The interactive console opens with a
5
+ one-shot survey of what the session has to work with: which context documents
6
+ the agent will read, which capability cards (skills) and prompt templates
7
+ (commands) were discovered on disk, and a count of each. This module
8
+ *gathers* that survey and nothing else — it returns a small, render-agnostic
9
+ :class:`StartupMap` the banner turns into a bordered panel. It performs only
10
+ read-only filesystem probes (existence checks plus the briefing loaders) and
11
+ holds no state; given the same disk it returns the same value, so a test can
12
+ drive it against a temp tree.
13
+
14
+ It also reads (when present) a ``CHANGELOG.md`` at the app root and folds it
15
+ into a :class:`StartupChangelog` the banner shows on a version bump — full on
16
+ the first sight of a new version, a one-line "updated to" note otherwise. The
17
+ decision of *which* of those to show is the gatherer's: it is handed the
18
+ running version and the last-seen version and reports a ``mode``, so the
19
+ banner stays a dumb renderer.
20
+
21
+ Nothing here imports Textual or Rich. The banner imports the *shapes* below
22
+ and the surface calls :func:`gather_startup` once at mount; the two never
23
+ share mutable state.
24
+
25
+ Port note: the TS module hardcoded its brand profile directory
26
+ (``.indusagi``); the Python build sources it from the single
27
+ :data:`~induscode.workspace.BRAND` record (``.pindusagi``) so a rebrand edits
28
+ one file, per the workspace contract.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import re
35
+ from dataclasses import dataclass
36
+ from typing import Final, Literal, TypeAlias
37
+
38
+ from induscode.briefing import Macro, MacroOrigin, SkillCard, SkillRoot
39
+ from induscode.briefing import gather_skill_cards, load_macros
40
+ from induscode.workspace import BRAND
41
+
42
+ __all__ = [
43
+ "StartupChangelog",
44
+ "StartupChangelogMode",
45
+ "StartupInputs",
46
+ "StartupMap",
47
+ "StartupNotice",
48
+ "StartupNoticeKind",
49
+ "StartupSection",
50
+ "gather_changelog",
51
+ "gather_startup",
52
+ ]
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Shapes
57
+ # ---------------------------------------------------------------------------
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class StartupSection:
62
+ """One labelled group inside the startup map.
63
+
64
+ A section is a title, the per-entry display lines (already shortened for
65
+ the terminal), and the *full* count the lines were derived from — which
66
+ may exceed the number of lines when the renderer elides a long list. A
67
+ section with no entries is omitted by :func:`gather_startup` rather than
68
+ emitted empty.
69
+ """
70
+
71
+ # The group heading (e.g. ``"Context"``, ``"Skills"``).
72
+ title: str
73
+ # The display lines, one per surfaced entry, already path-shortened.
74
+ lines: tuple[str, ...]
75
+ # How many entries the section spans (>= ``len(lines)``).
76
+ count: int
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class StartupMap:
81
+ """The gathered survey the banner renders as the "Startup Map" panel.
82
+
83
+ A flat ordered list of :class:`StartupSection` values. Empty when nothing
84
+ was found, in which case the banner renders no panel at all.
85
+ """
86
+
87
+ # The discovered sections, in display order.
88
+ sections: tuple[StartupSection, ...]
89
+
90
+
91
+ #: The changelog survey the banner may render as a "What is new" block.
92
+ #:
93
+ #: - ``none`` — no changelog to show (no file, or no version change).
94
+ #: - ``full`` — the running version is newer than last-seen *and* the
95
+ #: full body is worth showing; ``markdown`` carries it.
96
+ #: - ``condensed`` — a version change with no body to expand (or the body
97
+ #: suppressed); only the one-line note is shown.
98
+ StartupChangelogMode: TypeAlias = Literal["none", "full", "condensed"]
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class StartupChangelog:
103
+ """The gathered changelog survey, with the running version it was keyed
104
+ to."""
105
+
106
+ # Which of the three presentations the banner should render.
107
+ mode: StartupChangelogMode
108
+ # The running product version this survey was computed against.
109
+ version: str
110
+ # The condensed changelog body (present for ``full``), markdown source.
111
+ markdown: str | None = None
112
+
113
+
114
+ #: The per-line tone a startup notice carries.
115
+ StartupNoticeKind: TypeAlias = Literal["error", "warning", "info"]
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class StartupNotice:
120
+ """One out-of-band notice the banner renders above the wordmark region."""
121
+
122
+ # The tone the line is themed with.
123
+ kind: StartupNoticeKind
124
+ # The notice text.
125
+ text: str
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class StartupInputs:
130
+ """What :func:`gather_startup` is handed to compute the survey."""
131
+
132
+ # The working directory the session is scoped to.
133
+ cwd: str
134
+ # The user's home directory (where global resources live).
135
+ home: str
136
+ # The running product version (for the changelog survey).
137
+ version: str
138
+ # The last version the user has already seen a changelog for, if any.
139
+ last_seen_version: str | None = None
140
+ # Absolute path of the app-root ``CHANGELOG.md``, if the app knows one.
141
+ changelog_path: str | None = None
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Roots
146
+ # ---------------------------------------------------------------------------
147
+
148
+ #: The brand profile directory the standard resource roots sit under
149
+ #: (``.pindusagi``; sourced from the brand record — see the module docstring).
150
+ _PROFILE_DIR: Final[str] = BRAND.profile_dir_name
151
+
152
+ #: The context filenames the agent reads from a directory, in display order.
153
+ _CONTEXT_FILES: Final[tuple[str, ...]] = ("AGENTS.md", "CLAUDE.md")
154
+
155
+
156
+ def _skill_roots(cwd: str, home: str) -> list[SkillRoot]:
157
+ """The skill roots to scan, project before user so a project-local card
158
+ shadows a user-global one of the same name. Every root is optional."""
159
+ return [
160
+ SkillRoot(dir=os.path.join(cwd, _PROFILE_DIR, "skills"), origin="project"),
161
+ SkillRoot(dir=os.path.join(home, _PROFILE_DIR, "skills"), origin="user"),
162
+ ]
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class _MacroRoot:
167
+ """One prompt-template root, with its origin tag and display label."""
168
+
169
+ dir: str
170
+ origin: MacroOrigin
171
+ label: str
172
+
173
+
174
+ def _macro_roots(cwd: str, home: str) -> list[_MacroRoot]:
175
+ """The prompt-template (command) roots to scan, project before user."""
176
+ return [
177
+ _MacroRoot(
178
+ dir=os.path.join(cwd, _PROFILE_DIR, "commands"),
179
+ origin="project",
180
+ label="project",
181
+ ),
182
+ _MacroRoot(
183
+ dir=os.path.join(home, _PROFILE_DIR, "commands"),
184
+ origin="user",
185
+ label="user",
186
+ ),
187
+ ]
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Path display
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ def _shorten_path(path: str, cwd: str, home: str) -> str:
196
+ """Shorten an absolute path for display: collapse the cwd to ``./…`` and
197
+ the home directory to ``~/…``, leaving anything outside both untouched."""
198
+ if len(cwd) > 0 and path.startswith(cwd):
199
+ rest = path[len(cwd) :].lstrip("/\\")
200
+ return f"./{rest}" if len(rest) > 0 else "."
201
+ if len(home) > 0 and path.startswith(home):
202
+ return f"~{path[len(home):]}"
203
+ return path
204
+
205
+
206
+ #: The longest list a single section surfaces before the renderer elides it.
207
+ _MAX_SECTION_LINES: Final[int] = 6
208
+
209
+
210
+ def _cap_lines(lines: list[str]) -> list[str]:
211
+ """Cap a line list, replacing the overflow tail with a single "… N more"
212
+ marker so a section with many entries stays a few lines tall."""
213
+ if len(lines) <= _MAX_SECTION_LINES:
214
+ return list(lines)
215
+ head = lines[:_MAX_SECTION_LINES]
216
+ return [*head, f"... {len(lines) - _MAX_SECTION_LINES} more"]
217
+
218
+
219
+ def _section(title: str, lines: list[str]) -> StartupSection | None:
220
+ """Build a section from a title and its raw lines, returning ``None``
221
+ when the group is empty (so the caller can drop it). ``count`` is the
222
+ full pre-cap span."""
223
+ filtered = [line.strip() for line in lines]
224
+ filtered = [line for line in filtered if len(line) > 0]
225
+ if len(filtered) == 0:
226
+ return None
227
+ return StartupSection(title=title, lines=tuple(_cap_lines(filtered)), count=len(filtered))
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Resource discovery
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ def _gather_context(cwd: str, home: str) -> list[str]:
236
+ """The context documents the agent reads, gathered from the cwd and the
237
+ home directory. Each surfaced as a shortened path; duplicates (the same
238
+ file resolved via two roots) are not possible since cwd and home are
239
+ distinct roots, but a path seen twice is de-duplicated defensively."""
240
+ seen: set[str] = set()
241
+ out: list[str] = []
242
+ for root in (cwd, home):
243
+ if len(root) == 0:
244
+ continue
245
+ for name in _CONTEXT_FILES:
246
+ path = os.path.join(root, name)
247
+ if path in seen:
248
+ continue
249
+ seen.add(path)
250
+ if os.path.exists(path):
251
+ out.append(_shorten_path(path, cwd, home))
252
+ return out
253
+
254
+
255
+ def _gather_skills(cwd: str, home: str) -> list[str]:
256
+ """The capability cards discovered under the skill roots, each rendered
257
+ as its name plus shortened location. Never throws — a missing or
258
+ unreadable root yields nothing."""
259
+ cards: list[SkillCard]
260
+ try:
261
+ cards = list(gather_skill_cards(_skill_roots(cwd, home)).cards)
262
+ except Exception:
263
+ cards = []
264
+ return [f"{card.name} {_shorten_path(card.location, cwd, home)}" for card in cards]
265
+
266
+
267
+ def _gather_prompts(cwd: str, home: str) -> list[str]:
268
+ """The prompt templates discovered under the command roots, each rendered
269
+ as a slash token plus shortened source. Deduped by name across roots
270
+ (project wins). Never throws."""
271
+ lines: list[str] = []
272
+ claimed: set[str] = set()
273
+ for root in _macro_roots(cwd, home):
274
+ loaded: list[Macro]
275
+ try:
276
+ loaded = load_macros(root.dir, origin=root.origin, label=root.label)
277
+ except Exception:
278
+ loaded = []
279
+ for macro in loaded:
280
+ if macro.name in claimed:
281
+ continue
282
+ claimed.add(macro.name)
283
+ lines.append(f"/{macro.name} {_shorten_path(macro.source, cwd, home)}")
284
+ return lines
285
+
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # The survey
289
+ # ---------------------------------------------------------------------------
290
+
291
+
292
+ def gather_startup(inputs: StartupInputs) -> StartupMap:
293
+ """Gather the startup map: the context documents, skills, and prompt
294
+ templates the session opens with, each as a :class:`StartupSection`.
295
+ Empty sections are dropped, so a brand-new checkout with nothing on disk
296
+ yields ``StartupMap(sections=())`` and the banner renders no panel.
297
+
298
+ Pure with respect to its :class:`StartupInputs`: only read-only
299
+ filesystem probes, no writes, no caching.
300
+
301
+ :param inputs: the roots and version context to survey against
302
+ """
303
+ cwd, home = inputs.cwd, inputs.home
304
+ sections: list[StartupSection] = []
305
+
306
+ context = _section("Context", _gather_context(cwd, home))
307
+ if context is not None:
308
+ sections.append(context)
309
+
310
+ skills = _section("Skills", _gather_skills(cwd, home))
311
+ if skills is not None:
312
+ sections.append(skills)
313
+
314
+ prompts = _section("Prompts", _gather_prompts(cwd, home))
315
+ if prompts is not None:
316
+ sections.append(prompts)
317
+
318
+ return StartupMap(sections=tuple(sections))
319
+
320
+
321
+ # ---------------------------------------------------------------------------
322
+ # Changelog
323
+ # ---------------------------------------------------------------------------
324
+
325
+ #: One top-level release heading (``## 1.2.3`` or ``## [1.2.3] …``).
326
+ _ENTRY_HEADING_RE: Final[re.Pattern[str]] = re.compile(r"^##\s+\[?(\d+\.\d+\.\d+)\]?")
327
+
328
+
329
+ def _condense_changelog(markdown: str, last_seen: str | None) -> str:
330
+ """Condense a changelog markdown body to the entries above the last-seen
331
+ version.
332
+
333
+ The body is split on top-level ``## `` headings (one per release).
334
+ Entries are kept until the first heading whose version matches
335
+ ``last_seen``; that and everything older is dropped. With no last-seen
336
+ version, or no matching heading, the whole body is returned.
337
+ """
338
+ if last_seen is None or len(last_seen) == 0:
339
+ return markdown.strip()
340
+ kept: list[str] = []
341
+ for line in markdown.split("\n"):
342
+ heading = _ENTRY_HEADING_RE.match(line)
343
+ if heading is not None and heading.group(1) == last_seen:
344
+ break
345
+ kept.append(line)
346
+ joined = "\n".join(kept).strip()
347
+ return joined if len(joined) > 0 else markdown.strip()
348
+
349
+
350
+ def _reverse_changelog_entries(markdown: str) -> str:
351
+ """Reorder the release entries in a changelog body so the NEWEST is last.
352
+
353
+ The condensed body arrives newest-first (matching the on-disk
354
+ ``CHANGELOG.md``). At startup we want the latest release rendered at the
355
+ BOTTOM of the "What is new" block — closest to the prompt — so this
356
+ splits the body on top-level ``## `` release headings, preserves any
357
+ leading preamble (e.g. the ``# Changelog`` title) at the top, reverses
358
+ the order of the release blocks, and rejoins. Each block's internal
359
+ content order is left untouched.
360
+ """
361
+
362
+ def is_entry_heading(line: str) -> bool:
363
+ return _ENTRY_HEADING_RE.match(line) is not None
364
+
365
+ lines = markdown.split("\n")
366
+ first_entry = next((i for i, line in enumerate(lines) if is_entry_heading(line)), -1)
367
+ if first_entry == -1:
368
+ return markdown.strip()
369
+
370
+ preamble = "\n".join(lines[:first_entry]).strip()
371
+ blocks: list[list[str]] = []
372
+ for line in lines[first_entry:]:
373
+ if is_entry_heading(line):
374
+ blocks.append([line])
375
+ elif len(blocks) > 0:
376
+ blocks[-1].append(line)
377
+ blocks.reverse()
378
+
379
+ body = "\n\n".join(
380
+ joined for joined in ("\n".join(block).strip() for block in blocks) if len(joined) > 0
381
+ )
382
+ return f"{preamble}\n\n{body}" if len(preamble) > 0 else body
383
+
384
+
385
+ def gather_changelog(inputs: StartupInputs) -> StartupChangelog:
386
+ """Gather the changelog survey for the banner.
387
+
388
+ The presentation depends on the version delta:
389
+
390
+ - no ``CHANGELOG.md``, or the running version equals the last-seen
391
+ version → ``mode="none"`` (nothing shown);
392
+ - a version change with a non-empty condensed body → ``mode="full"`` and
393
+ the body in ``markdown``;
394
+ - a version change whose condensed body is empty → ``mode="condensed"``
395
+ (only the one-line "updated to" note).
396
+
397
+ When the app does not track a last-seen version (``last_seen_version``
398
+ absent), a present ``CHANGELOG.md`` is always surfaced condensed so the
399
+ note still appears.
400
+
401
+ :param inputs: the version context and optional changelog path
402
+ """
403
+ version = inputs.version
404
+ last_seen_version = inputs.last_seen_version
405
+ changelog_path = inputs.changelog_path
406
+
407
+ none = StartupChangelog(mode="none", version=version)
408
+
409
+ if changelog_path is None or not os.path.exists(changelog_path):
410
+ return none
411
+ if last_seen_version is not None and last_seen_version == version:
412
+ return none
413
+
414
+ try:
415
+ with open(changelog_path, encoding="utf-8") as handle:
416
+ raw = handle.read()
417
+ except Exception:
418
+ return none
419
+
420
+ condensed = _condense_changelog(raw, last_seen_version)
421
+ if len(condensed) == 0:
422
+ return StartupChangelog(mode="condensed", version=version)
423
+ # With no last-seen anchor we cannot tell a true bump from a first
424
+ # launch, so the body is surfaced condensed (one-line) rather than as a
425
+ # full block.
426
+ if last_seen_version is None:
427
+ return StartupChangelog(mode="condensed", version=version)
428
+ # Render newest-at-bottom in the startup "What is new" block (closest to
429
+ # the prompt), even though CHANGELOG.md is stored newest-first.
430
+ return StartupChangelog(
431
+ mode="full",
432
+ version=version,
433
+ markdown=_reverse_changelog_entries(condensed),
434
+ )
@@ -0,0 +1,44 @@
1
+ """Console theme engine — public barrel.
2
+
3
+ Port of TS ``src/console/theme/index.ts``. The theme engine turns the
4
+ console's own re-derived accent ramps into the framework theme projection
5
+ every component renders against. The pipeline is: a raw
6
+ :class:`~induscode.console.contract.ThemePalette` (this package's own hex
7
+ ramps) → semantic :class:`~induscode.console.contract.ThemeTokens` via
8
+ :func:`derive_tokens` → a framework :class:`~indusagi.react_ink.ThemeBundle`
9
+ (painter adapter + Textual Theme + Pygments style) via :func:`theme_bundle` →
10
+ a fully resolved :class:`~induscode.console.contract.ConsoleTheme`. The four
11
+ built-in schemes are assembled once into :data:`THEMES` and obtained through
12
+ :func:`resolve_theme`.
13
+
14
+ Consumers import from ``induscode.console.theme`` (or the ``induscode.console``
15
+ barrel) and never reach into the individual palette/tokens/adapter/resolve
16
+ modules.
17
+ """
18
+
19
+ from .adapter import framework_colors, theme_adapter, theme_bundle
20
+ from .palette import (
21
+ DAYLIGHT_CB_PALETTE,
22
+ DAYLIGHT_PALETTE,
23
+ MIDNIGHT_CB_PALETTE,
24
+ MIDNIGHT_PALETTE,
25
+ PALETTES,
26
+ )
27
+ from .resolve import THEME_SCHEMES, THEMES, resolve_theme
28
+ from .tokens import derive_tokens, luminance
29
+
30
+ __all__ = [
31
+ "DAYLIGHT_CB_PALETTE",
32
+ "DAYLIGHT_PALETTE",
33
+ "MIDNIGHT_CB_PALETTE",
34
+ "MIDNIGHT_PALETTE",
35
+ "PALETTES",
36
+ "THEMES",
37
+ "THEME_SCHEMES",
38
+ "derive_tokens",
39
+ "framework_colors",
40
+ "luminance",
41
+ "resolve_theme",
42
+ "theme_adapter",
43
+ "theme_bundle",
44
+ ]
@@ -0,0 +1,168 @@
1
+ """Theme adapter binding — the single boundary where the console's semantic
2
+ tokens become concrete terminal colours.
3
+
4
+ Port of TS ``src/console/theme/adapter.ts``. The framework renders colour
5
+ through a :class:`~indusagi.react_ink.ThemeAdapter` (TS-name alias
6
+ ``InkThemeAdapter``): a flat ``colors`` mapping plus Rich-backed
7
+ ``color(key, text)`` / ``background(key, …)`` / ``dim`` / ``muted`` painters,
8
+ all produced by ``create_theme_adapter(name, colors)``. The framework
9
+ components look colours up by *their* key vocabulary (``accent``, ``error``,
10
+ ``success``, ``warning``, ``borderMuted``, ``bashBorder``, ``userMessage``,
11
+ ``customMessage``, ``text``, ``dim``, ``muted``). This module is the one
12
+ place those framework keys are populated — each from a console
13
+ :class:`~induscode.console.contract.ThemeTokens` role — so the rest of the
14
+ console never speaks the framework's key names and the framework never sees a
15
+ console role name. The key strings are kept VERBATIM from TS (camelCase):
16
+ they are the framework's wire vocabulary, not Python identifiers.
17
+
18
+ Port delta (locked; analysis 02 §5): the Python boundary call is
19
+ ``create_theme_bundle``, which yields the adapter **plus** a
20
+ ``textual.theme.Theme`` (every key/role as a CSS variable like
21
+ ``$user-message`` / ``$diff-added-bg``) and a Pygments style — so registering
22
+ the four schemes as Textual Themes gives live preview-before-commit for free.
23
+ :func:`theme_adapter` is kept for parity (the TS boundary) and for tests that
24
+ only need the painter surface.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from indusagi.react_ink import (
30
+ InkThemeAdapter,
31
+ ThemeBundle,
32
+ create_theme_adapter,
33
+ create_theme_bundle,
34
+ )
35
+
36
+ from ..contract import ThemeTokens
37
+
38
+ __all__ = [
39
+ "framework_colors",
40
+ "theme_adapter",
41
+ "theme_bundle",
42
+ ]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Framework colour-key projection
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ def framework_colors(tokens: ThemeTokens) -> dict[str, str]:
51
+ """Project the console's semantic tokens onto the framework's colour-key
52
+ mapping.
53
+
54
+ The keys here are the framework's own (the names its react-ink components
55
+ pass to ``theme.color(...)`` / ``theme.background(...)``), each filled
56
+ from a console role. The framework's ``create_theme_adapter`` falls back
57
+ gracefully for any key it asks for that is absent, but we populate every
58
+ key the shipped components are known to request so nothing degrades to a
59
+ default grey.
60
+
61
+ .. code-block:: text
62
+
63
+ framework key ← console role
64
+ -------------- ------------------------------------------------
65
+ text ← body_text (default foreground / fallback)
66
+ dim ← muted_text (the adapter's dim() source)
67
+ muted ← muted_text (the adapter's muted() source)
68
+ accent ← signal (primary accent highlight)
69
+ borderMuted ← quiet_frame (de-emphasised borders/separators)
70
+ bashBorder ← frame (the bash-block border tone)
71
+ userMessage ← prompt_surface (user-turn background tint)
72
+ customMessage ← card_accent (custom/plugin message tint)
73
+ success ← affirm
74
+ warning ← caution
75
+ error ← alarm
76
+ info ← notice (informational status tone)
77
+ highlight ← ink_text (high-contrast on-accent text)
78
+
79
+ Plus the markdown / diff / syntax-highlight role keys the framework's
80
+ rich render path (``theme.role(...)`` / ``theme.role_background(...)``)
81
+ resolves. These key names match the framework adapter's default
82
+ role → key map exactly (``DEFAULT_ROLE_KEYS``), so the styled transcript,
83
+ colored diffs, and fenced-code highlighting resolve their colours
84
+ straight from the console's derived tokens:
85
+
86
+ .. code-block:: text
87
+
88
+ codeInline ← code_inline (inline code foreground)
89
+ heading ← heading (markdown heading foreground)
90
+ blockquoteBar ← blockquote_bar (dim quote bar)
91
+ diffAddedBg ← diff_added_bg (added-line background tint)
92
+ diffRemovedBg ← diff_removed_bg (removed-line background tint)
93
+ diffAddedText ← diff_added_text (added foreground / ``+``)
94
+ diffRemovedText ← diff_removed_text (removed foreground / ``-``)
95
+ synKeyword ← syn_keyword (syntax: keywords)
96
+ synString ← syn_string (syntax: strings)
97
+ synNumber ← syn_number (syntax: numbers)
98
+ synComment ← syn_comment (syntax: comments)
99
+ synType ← syn_type (syntax: types / classes)
100
+
101
+ :param tokens: the derived semantic token map for a scheme
102
+ :returns: a flat colour mapping keyed by the framework's vocabulary
103
+ """
104
+ return {
105
+ "text": tokens.body_text,
106
+ "dim": tokens.muted_text,
107
+ "muted": tokens.muted_text,
108
+ "accent": tokens.signal,
109
+ "borderMuted": tokens.quiet_frame,
110
+ "bashBorder": tokens.frame,
111
+ "userMessage": tokens.prompt_surface,
112
+ "customMessage": tokens.card_accent,
113
+ "success": tokens.affirm,
114
+ "warning": tokens.caution,
115
+ "error": tokens.alarm,
116
+ "info": tokens.notice,
117
+ "highlight": tokens.ink_text,
118
+ # Rich-render role keys (match the framework adapter's default role
119
+ # map).
120
+ "codeInline": tokens.code_inline,
121
+ "heading": tokens.heading,
122
+ "blockquoteBar": tokens.blockquote_bar,
123
+ "diffAddedBg": tokens.diff_added_bg,
124
+ "diffRemovedBg": tokens.diff_removed_bg,
125
+ "diffAddedText": tokens.diff_added_text,
126
+ "diffRemovedText": tokens.diff_removed_text,
127
+ "synKeyword": tokens.syn_keyword,
128
+ "synString": tokens.syn_string,
129
+ "synNumber": tokens.syn_number,
130
+ "synComment": tokens.syn_comment,
131
+ "synType": tokens.syn_type,
132
+ }
133
+
134
+
135
+ def theme_adapter(scheme_name: str, tokens: ThemeTokens) -> InkThemeAdapter:
136
+ """Build the framework :class:`~indusagi.react_ink.InkThemeAdapter` for a
137
+ scheme from its tokens.
138
+
139
+ The TS boundary call: derive the framework colour mapping from the
140
+ console's semantic tokens, then hand it to ``create_theme_adapter`` under
141
+ the scheme's name. The full Python boundary is :func:`theme_bundle`,
142
+ which wraps this same projection.
143
+
144
+ :param scheme_name: the scheme identity, used as the adapter's ``name``
145
+ :param tokens: the derived semantic token map for that scheme
146
+ :returns: the Rich-backed framework adapter
147
+ """
148
+ return create_theme_adapter(scheme_name, framework_colors(tokens))
149
+
150
+
151
+ def theme_bundle(scheme_name: str, tokens: ThemeTokens, *, dark: bool = True) -> ThemeBundle:
152
+ """Build the full framework :class:`~indusagi.react_ink.ThemeBundle` for a
153
+ scheme from its tokens.
154
+
155
+ The Python boundary call (port delta; module docstring): one projection
156
+ of the tokens through :func:`framework_colors`, handed to
157
+ ``create_theme_bundle`` — yielding the painter adapter, the registrable
158
+ ``textual.theme.Theme`` (CSS variables per key/role), and the Pygments
159
+ style for fenced-code highlighting. This is the *only* place
160
+ ``create_theme_bundle`` is invoked for a console theme.
161
+
162
+ :param scheme_name: the scheme identity, used as the bundle's ``name``
163
+ :param tokens: the derived semantic token map for that scheme
164
+ :param dark: whether the scheme targets a dark terminal (Textual's
165
+ light/dark axis)
166
+ :returns: adapter + Textual Theme + Pygments style for the scheme
167
+ """
168
+ return create_theme_bundle(scheme_name, framework_colors(tokens), dark=dark)