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