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,313 @@
|
|
|
1
|
+
"""Settings contract — the typed surface of the user-tunable preferences an
|
|
2
|
+
interactive coding-agent session reads at startup and while it runs.
|
|
3
|
+
|
|
4
|
+
This module declares *only* shapes and frozen defaults — no I/O, no store, no
|
|
5
|
+
filesystem. It is the single place every preference key is named, typed, and
|
|
6
|
+
given a fallback. The two-tier store (:mod:`.manager`) is written against the
|
|
7
|
+
:class:`Preferences` record declared here, and any console surface that
|
|
8
|
+
toggles a preference reads its value through the same record.
|
|
9
|
+
|
|
10
|
+
Design stance
|
|
11
|
+
-------------
|
|
12
|
+
- Every field is optional (``None`` plays the TS ``undefined`` "key not
|
|
13
|
+
present" role — no preference legally holds null as a real value) so a
|
|
14
|
+
partially-written file is a legal :class:`Preferences` value; the store
|
|
15
|
+
fills the gaps from :data:`DEFAULT_PREFERENCES` when a reader asks for a
|
|
16
|
+
concrete value.
|
|
17
|
+
- Reasoning-effort vocabulary is borrowed from the framework
|
|
18
|
+
(``indusagi.agent.ThinkingLevel``) rather than re-declared, so the agent
|
|
19
|
+
and the UI speak the same words.
|
|
20
|
+
- The other small string unions (the colour scheme is a free string, while
|
|
21
|
+
:data:`EscapeAction` / :data:`DeliveryMode` are pinned) name the surfaces a
|
|
22
|
+
console reads.
|
|
23
|
+
|
|
24
|
+
Port notes (TS ``src/settings/contract.ts``)
|
|
25
|
+
--------------------------------------------
|
|
26
|
+
- The TS ``Preferences`` interface (23 optional camelCase keys) becomes a
|
|
27
|
+
frozen dataclass with snake_case fields. The on-disk JSON keeps the TS
|
|
28
|
+
camelCase spelling verbatim — a settings file written by the TS agent reads
|
|
29
|
+
back unchanged — and the explicit :data:`FIELD_TO_JSON_KEY` /
|
|
30
|
+
:data:`JSON_TO_FIELD_KEY` maps are the single place the two spellings are
|
|
31
|
+
tied together (no mechanical case converter to silently drift).
|
|
32
|
+
- List-typed values are tuples so :data:`DEFAULT_PREFERENCES` is deeply
|
|
33
|
+
immutable and safe to share without a defensive copy (the TS record relied
|
|
34
|
+
on ``Object.freeze`` plus discipline).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from dataclasses import dataclass, fields
|
|
40
|
+
from types import MappingProxyType
|
|
41
|
+
from typing import Any, Final, Literal, TypeAlias, TypeGuard, get_args
|
|
42
|
+
|
|
43
|
+
from indusagi.agent import ThinkingLevel
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"DEFAULT_PREFERENCES",
|
|
47
|
+
"DELIVERY_MODES",
|
|
48
|
+
"DeliveryMode",
|
|
49
|
+
"ESCAPE_ACTIONS",
|
|
50
|
+
"EscapeAction",
|
|
51
|
+
"FIELD_TO_JSON_KEY",
|
|
52
|
+
"JSON_TO_FIELD_KEY",
|
|
53
|
+
"Preferences",
|
|
54
|
+
"SETTING_KEYS",
|
|
55
|
+
"SettingKey",
|
|
56
|
+
"ThinkingLevel",
|
|
57
|
+
"canonical_key",
|
|
58
|
+
"is_delivery_mode",
|
|
59
|
+
"is_escape_action",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Narrow vocabularies
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
#: What a double-tap of the escape key does in the interactive console.
|
|
68
|
+
#:
|
|
69
|
+
#: - ``tree`` — open the turn history as a navigable tree of branches.
|
|
70
|
+
#: - ``fork`` — open the prior-turn picker to branch off an earlier turn.
|
|
71
|
+
#: - ``clear`` — wipe the composer buffer.
|
|
72
|
+
#:
|
|
73
|
+
#: The legacy ``rewind`` / ``branch`` aliases are still accepted and treated
|
|
74
|
+
#: as ``fork`` / ``tree`` respectively, so an older preference file keeps
|
|
75
|
+
#: working. They are part of the type but not of :data:`ESCAPE_ACTIONS`.
|
|
76
|
+
EscapeAction: TypeAlias = Literal["tree", "fork", "clear", "rewind", "branch"]
|
|
77
|
+
|
|
78
|
+
#: Every canonical :data:`EscapeAction` value (legacy aliases excluded), as a
|
|
79
|
+
#: frozen tuple for menus and guards.
|
|
80
|
+
ESCAPE_ACTIONS: Final[tuple[EscapeAction, ...]] = ("tree", "fork", "clear")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_escape_action(value: object) -> TypeGuard[EscapeAction]:
|
|
84
|
+
"""Narrow an arbitrary value to a known (canonical) :data:`EscapeAction`."""
|
|
85
|
+
return isinstance(value, str) and value in ESCAPE_ACTIONS
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
#: How a queue of pending turns (steering corrections or follow-up prompts)
|
|
89
|
+
#: is drained back into the run.
|
|
90
|
+
#:
|
|
91
|
+
#: - ``all`` — release every queued turn at once on the next handoff.
|
|
92
|
+
#: - ``one-at-a-time`` — release a single queued turn per handoff.
|
|
93
|
+
DeliveryMode: TypeAlias = Literal["all", "one-at-a-time"]
|
|
94
|
+
|
|
95
|
+
#: Every :data:`DeliveryMode` value, as a frozen tuple for menus and guards.
|
|
96
|
+
DELIVERY_MODES: Final[tuple[DeliveryMode, ...]] = ("all", "one-at-a-time")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def is_delivery_mode(value: object) -> TypeGuard[DeliveryMode]:
|
|
100
|
+
"""Narrow an arbitrary value to a known :data:`DeliveryMode`."""
|
|
101
|
+
return isinstance(value, str) and value in DELIVERY_MODES
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Preference record
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class Preferences:
|
|
111
|
+
"""Every preference an interactive coding-agent session reads, as one
|
|
112
|
+
flat, fully-optional record.
|
|
113
|
+
|
|
114
|
+
A value of this type may be all-``None`` (a brand-new install), a sparse
|
|
115
|
+
project override, or a fully-populated snapshot — all three are legal.
|
|
116
|
+
Concrete reads always go through the store, which layers the project file
|
|
117
|
+
over the global file over :data:`DEFAULT_PREFERENCES`, so a reader never
|
|
118
|
+
sees ``None`` for a key with a default.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
# Named colour scheme the console renders with (e.g. "midnight").
|
|
122
|
+
colour_scheme: str | None = None
|
|
123
|
+
# Whether inline image content is rendered in the transcript.
|
|
124
|
+
show_images: bool | None = None
|
|
125
|
+
# Whether the model's reasoning / thinking text is shown as it streams.
|
|
126
|
+
show_reasoning: bool | None = None
|
|
127
|
+
# When True, the reasoning block is folded away even if the model emits one.
|
|
128
|
+
hide_thinking: bool | None = None
|
|
129
|
+
# Whether oversized images are shrunk to fit before reaching a provider.
|
|
130
|
+
image_auto_resize: bool | None = None
|
|
131
|
+
# When True, image content is withheld from providers entirely.
|
|
132
|
+
block_images: bool | None = None
|
|
133
|
+
# Whether discovered skills are surfaced as their own slash commands.
|
|
134
|
+
enable_skill_commands: bool | None = None
|
|
135
|
+
# How queued steering corrections are released back into the run.
|
|
136
|
+
steering_mode: DeliveryMode | None = None
|
|
137
|
+
# How queued follow-up prompts are released back into the run.
|
|
138
|
+
follow_up_mode: DeliveryMode | None = None
|
|
139
|
+
# Prefer a condensed changelog after the console updates itself.
|
|
140
|
+
collapse_changelog: bool | None = None
|
|
141
|
+
# Reveal the terminal's own cursor instead of the software-drawn caret.
|
|
142
|
+
show_hardware_cursor: bool | None = None
|
|
143
|
+
# Horizontal padding, in columns, around the prompt editor.
|
|
144
|
+
editor_padding_x: int | None = None
|
|
145
|
+
# Glob / id patterns selecting picker models; empty means all.
|
|
146
|
+
enabled_models: tuple[str, ...] | None = None
|
|
147
|
+
# Provider id the session defaults to when none is named.
|
|
148
|
+
default_provider: str | None = None
|
|
149
|
+
# Model id the session opens with under ``default_provider``.
|
|
150
|
+
default_model: str | None = None
|
|
151
|
+
# Reasoning effort the session requests by default.
|
|
152
|
+
default_thinking_level: ThinkingLevel | None = None
|
|
153
|
+
# Suppress the banner / tips shown on a normal interactive launch.
|
|
154
|
+
quiet_startup: bool | None = None
|
|
155
|
+
# The last product version whose full masthead the user has already seen;
|
|
156
|
+
# the banner auto-condenses when this equals the running version.
|
|
157
|
+
last_seen_version: str | None = None
|
|
158
|
+
# Opt-in static colour-sweep flourish tinting the startup wordmark.
|
|
159
|
+
logo_sweep: bool | None = None
|
|
160
|
+
# When set, motion-flavoured flourishes (e.g. the logo sweep) are off.
|
|
161
|
+
reduced_motion: bool | None = None
|
|
162
|
+
# Whether the window-budget manager compacts the transcript automatically.
|
|
163
|
+
auto_compact: bool | None = None
|
|
164
|
+
# What a double-escape does in the console.
|
|
165
|
+
double_escape_action: EscapeAction | None = None
|
|
166
|
+
# Extension-package sources the launcher installs (npm / git / local).
|
|
167
|
+
extension_packages: tuple[str, ...] | None = None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
#: The set of legal preference keys (snake_case field names), as a Literal
|
|
171
|
+
#: union — the Python analogue of the TS ``keyof Preferences``.
|
|
172
|
+
SettingKey: TypeAlias = Literal[
|
|
173
|
+
"colour_scheme",
|
|
174
|
+
"show_images",
|
|
175
|
+
"show_reasoning",
|
|
176
|
+
"hide_thinking",
|
|
177
|
+
"image_auto_resize",
|
|
178
|
+
"block_images",
|
|
179
|
+
"enable_skill_commands",
|
|
180
|
+
"steering_mode",
|
|
181
|
+
"follow_up_mode",
|
|
182
|
+
"collapse_changelog",
|
|
183
|
+
"show_hardware_cursor",
|
|
184
|
+
"editor_padding_x",
|
|
185
|
+
"enabled_models",
|
|
186
|
+
"default_provider",
|
|
187
|
+
"default_model",
|
|
188
|
+
"default_thinking_level",
|
|
189
|
+
"quiet_startup",
|
|
190
|
+
"last_seen_version",
|
|
191
|
+
"logo_sweep",
|
|
192
|
+
"reduced_motion",
|
|
193
|
+
"auto_compact",
|
|
194
|
+
"double_escape_action",
|
|
195
|
+
"extension_packages",
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
#: Every preference key, as a frozen tuple for iteration and validation
|
|
199
|
+
#: (derived from :data:`SettingKey` so the two can never drift).
|
|
200
|
+
SETTING_KEYS: Final[tuple[str, ...]] = get_args(SettingKey)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Field ↔ JSON-key alias maps
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
#: Explicit snake_case-field → camelCase-JSON-key map, in declaration order.
|
|
208
|
+
#: The on-disk spelling is the TS one, kept verbatim; this map is the single
|
|
209
|
+
#: tie between the two namings.
|
|
210
|
+
FIELD_TO_JSON_KEY: Final[MappingProxyType[str, str]] = MappingProxyType(
|
|
211
|
+
{
|
|
212
|
+
"colour_scheme": "colourScheme",
|
|
213
|
+
"show_images": "showImages",
|
|
214
|
+
"show_reasoning": "showReasoning",
|
|
215
|
+
"hide_thinking": "hideThinking",
|
|
216
|
+
"image_auto_resize": "imageAutoResize",
|
|
217
|
+
"block_images": "blockImages",
|
|
218
|
+
"enable_skill_commands": "enableSkillCommands",
|
|
219
|
+
"steering_mode": "steeringMode",
|
|
220
|
+
"follow_up_mode": "followUpMode",
|
|
221
|
+
"collapse_changelog": "collapseChangelog",
|
|
222
|
+
"show_hardware_cursor": "showHardwareCursor",
|
|
223
|
+
"editor_padding_x": "editorPaddingX",
|
|
224
|
+
"enabled_models": "enabledModels",
|
|
225
|
+
"default_provider": "defaultProvider",
|
|
226
|
+
"default_model": "defaultModel",
|
|
227
|
+
"default_thinking_level": "defaultThinkingLevel",
|
|
228
|
+
"quiet_startup": "quietStartup",
|
|
229
|
+
"last_seen_version": "lastSeenVersion",
|
|
230
|
+
"logo_sweep": "logoSweep",
|
|
231
|
+
"reduced_motion": "reducedMotion",
|
|
232
|
+
"auto_compact": "autoCompact",
|
|
233
|
+
"double_escape_action": "doubleEscapeAction",
|
|
234
|
+
"extension_packages": "extensionPackages",
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
#: The reverse map: camelCase JSON key → snake_case field name.
|
|
239
|
+
JSON_TO_FIELD_KEY: Final[MappingProxyType[str, str]] = MappingProxyType(
|
|
240
|
+
{json_key: field for field, json_key in FIELD_TO_JSON_KEY.items()}
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def canonical_key(key: str) -> str:
|
|
245
|
+
"""Normalise a preference key to its canonical snake_case field name.
|
|
246
|
+
|
|
247
|
+
Accepts either the snake_case field name (the Python surface) or the
|
|
248
|
+
camelCase JSON spelling (the on-disk surface). Raises :class:`KeyError`
|
|
249
|
+
for anything else, so a typo is a loud error rather than a silent miss.
|
|
250
|
+
"""
|
|
251
|
+
if key in FIELD_TO_JSON_KEY:
|
|
252
|
+
return key
|
|
253
|
+
field = JSON_TO_FIELD_KEY.get(key)
|
|
254
|
+
if field is not None:
|
|
255
|
+
return field
|
|
256
|
+
raise KeyError(f"unknown preference key: {key!r}")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# Defaults
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
#: The fallback value for every preference, used whenever neither the project
|
|
264
|
+
#: nor the global tier supplies a key.
|
|
265
|
+
#:
|
|
266
|
+
#: Frozen (and tuple-valued where the TS used arrays) so it can be shared
|
|
267
|
+
#: without a defensive copy. Every field carries a concrete (non-``None``)
|
|
268
|
+
#: value — the analogue of the TS ``Required<Preferences>`` — which is what
|
|
269
|
+
#: lets a typed reader resolve a guaranteed result. Values are the TS
|
|
270
|
+
#: fallbacks, verbatim.
|
|
271
|
+
DEFAULT_PREFERENCES: Final[Preferences] = Preferences(
|
|
272
|
+
colour_scheme="default",
|
|
273
|
+
show_images=True,
|
|
274
|
+
show_reasoning=True,
|
|
275
|
+
hide_thinking=False,
|
|
276
|
+
image_auto_resize=True,
|
|
277
|
+
block_images=False,
|
|
278
|
+
enable_skill_commands=True,
|
|
279
|
+
steering_mode="all",
|
|
280
|
+
follow_up_mode="all",
|
|
281
|
+
collapse_changelog=False,
|
|
282
|
+
show_hardware_cursor=False,
|
|
283
|
+
editor_padding_x=1,
|
|
284
|
+
enabled_models=(),
|
|
285
|
+
default_provider="anthropic",
|
|
286
|
+
default_model="",
|
|
287
|
+
default_thinking_level="medium",
|
|
288
|
+
quiet_startup=False,
|
|
289
|
+
last_seen_version="",
|
|
290
|
+
logo_sweep=False,
|
|
291
|
+
reduced_motion=False,
|
|
292
|
+
auto_compact=True,
|
|
293
|
+
double_escape_action="clear",
|
|
294
|
+
extension_packages=(),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _default_value(field: str) -> Any:
|
|
299
|
+
"""The concrete default for one canonical field name."""
|
|
300
|
+
return getattr(DEFAULT_PREFERENCES, field)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# The dataclass, the SettingKey union, and the alias map must always describe
|
|
304
|
+
# the same 23-key set, and the defaults must be fully populated; assert it
|
|
305
|
+
# once at import so a drift is an immediate, loud error (the Python analogue
|
|
306
|
+
# of the TS `Required<Preferences>` / `keyof` constraints).
|
|
307
|
+
_FIELD_NAMES = {field.name for field in fields(Preferences)}
|
|
308
|
+
assert set(SETTING_KEYS) == _FIELD_NAMES, "SettingKey must match Preferences fields"
|
|
309
|
+
assert set(FIELD_TO_JSON_KEY) == _FIELD_NAMES, "alias map must cover Preferences fields"
|
|
310
|
+
assert len(JSON_TO_FIELD_KEY) == len(FIELD_TO_JSON_KEY), "alias map must be one-to-one"
|
|
311
|
+
assert all(
|
|
312
|
+
_default_value(name) is not None for name in SETTING_KEYS
|
|
313
|
+
), "DEFAULT_PREFERENCES must populate every key"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Settings manager — the two-tier reader/writer over the preference record.
|
|
2
|
+
|
|
3
|
+
Two files back the same :class:`~.contract.Preferences` shape:
|
|
4
|
+
|
|
5
|
+
- the **global** file, one per machine, living in the resolved workspace
|
|
6
|
+
profile directory (``Workspace.settings_path`` — ``<profile_root>/settings.json``);
|
|
7
|
+
- the **project** file, one per checkout, living beside the working tree in
|
|
8
|
+
the brand-named config directory (``<cwd>/.pindusagi/settings.json``).
|
|
9
|
+
|
|
10
|
+
A read layers the project tier over the global tier over
|
|
11
|
+
:data:`~.contract.DEFAULT_PREFERENCES`: a key set in the project file wins,
|
|
12
|
+
then the global file, then the built-in default — so a reader of
|
|
13
|
+
:meth:`PreferenceStore.get` always receives a concrete value. A write through
|
|
14
|
+
:meth:`PreferenceStore.set` lands in the **project** tier only; the global
|
|
15
|
+
file is left untouched, which keeps a per-checkout override from silently
|
|
16
|
+
leaking machine-wide.
|
|
17
|
+
|
|
18
|
+
Loading is tolerant: a missing file is the empty record, and a corrupt file
|
|
19
|
+
(bad JSON, or a JSON value that is not an object) degrades to the empty
|
|
20
|
+
record rather than raising, so one mangled file never blocks startup.
|
|
21
|
+
|
|
22
|
+
Construction is injectable. :meth:`PreferenceStore.from_workspace` resolves
|
|
23
|
+
the two paths from a :class:`~induscode.workspace.Workspace` and the working
|
|
24
|
+
directory; :meth:`PreferenceStore.at_paths` pins both paths directly, which
|
|
25
|
+
is what the tests drive against a temp dir so the real home is never read or
|
|
26
|
+
written.
|
|
27
|
+
|
|
28
|
+
Port notes (TS ``src/settings/manager.ts``)
|
|
29
|
+
-------------------------------------------
|
|
30
|
+
- The on-disk JSON keys are the TS camelCase spellings; tiers are held in
|
|
31
|
+
memory under the canonical snake_case field names and translated through
|
|
32
|
+
the contract's explicit alias maps on load and save.
|
|
33
|
+
- JSON ``null`` is treated as "key not present" (the closest analogue of the
|
|
34
|
+
TS ``undefined`` hole), and clearing a staged override is done by setting
|
|
35
|
+
it to ``None`` (TS: ``undefined``).
|
|
36
|
+
- Unknown JSON keys are preserved verbatim through a load → save round-trip
|
|
37
|
+
(as the TS shallow-copy semantics did), but never surface through
|
|
38
|
+
:meth:`PreferenceStore.get` / :meth:`PreferenceStore.snapshot`.
|
|
39
|
+
- JSON arrays are coerced to tuples on load so tier values compare equal to
|
|
40
|
+
the tuple-valued defaults.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
from dataclasses import dataclass
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any
|
|
50
|
+
|
|
51
|
+
from ..workspace import BRAND, Workspace
|
|
52
|
+
|
|
53
|
+
from .contract import (
|
|
54
|
+
DEFAULT_PREFERENCES,
|
|
55
|
+
FIELD_TO_JSON_KEY,
|
|
56
|
+
SETTING_KEYS,
|
|
57
|
+
Preferences,
|
|
58
|
+
canonical_key,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"PreferenceLocations",
|
|
63
|
+
"PreferenceStore",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
# Locations
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class PreferenceLocations:
|
|
74
|
+
"""The resolved location of the two preference tiers.
|
|
75
|
+
|
|
76
|
+
Both are absolute paths. Either file may be absent on disk — the store
|
|
77
|
+
treats a missing file as the empty record. (The TS record's ``global`` /
|
|
78
|
+
``project`` member names gain a ``_path`` suffix because ``global`` is a
|
|
79
|
+
Python keyword.)
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
# Machine-wide preference file (the global tier).
|
|
83
|
+
global_path: Path
|
|
84
|
+
# Per-checkout preference file (the project tier).
|
|
85
|
+
project_path: Path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Tolerant load / write
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _freeze(value: Any) -> Any:
|
|
94
|
+
"""Coerce a JSON array to a tuple so tier values match the tuple-valued
|
|
95
|
+
defaults; everything else passes through unchanged."""
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
return tuple(value)
|
|
98
|
+
return value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _load_tier(file_path: Path) -> dict[str, Any]:
|
|
102
|
+
"""Read one preference file, degrading to the empty record on any problem.
|
|
103
|
+
|
|
104
|
+
A path that does not exist, a file that fails to parse as JSON, or a JSON
|
|
105
|
+
value that is not an object all resolve to ``{}`` — never a raise. Known
|
|
106
|
+
keys (camelCase or snake_case) are stored under their canonical
|
|
107
|
+
snake_case field name; unknown keys are kept verbatim so a save does not
|
|
108
|
+
strip them; ``null`` values are dropped (a null is "key not present").
|
|
109
|
+
"""
|
|
110
|
+
try:
|
|
111
|
+
raw = file_path.read_text(encoding="utf-8")
|
|
112
|
+
except OSError:
|
|
113
|
+
return {}
|
|
114
|
+
try:
|
|
115
|
+
parsed = json.loads(raw)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return {}
|
|
118
|
+
if not isinstance(parsed, dict):
|
|
119
|
+
return {}
|
|
120
|
+
tier: dict[str, Any] = {}
|
|
121
|
+
for key, value in parsed.items():
|
|
122
|
+
if value is None:
|
|
123
|
+
continue
|
|
124
|
+
try:
|
|
125
|
+
tier[canonical_key(key)] = _freeze(value)
|
|
126
|
+
except KeyError:
|
|
127
|
+
tier[key] = _freeze(value)
|
|
128
|
+
return tier
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _write_tier(file_path: Path, tier: dict[str, Any]) -> None:
|
|
132
|
+
"""Write one preference tier as pretty JSON (camelCase keys), creating
|
|
133
|
+
the parent directory if needed. Pure side effect; the caller owns the
|
|
134
|
+
in-memory tier."""
|
|
135
|
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
payload = {FIELD_TO_JSON_KEY.get(key, key): value for key, value in tier.items()}
|
|
137
|
+
file_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Two-tier store
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class PreferenceStore:
|
|
146
|
+
"""Two-tier preference store: project-over-global-over-default on read,
|
|
147
|
+
and a project-tier write on save.
|
|
148
|
+
|
|
149
|
+
Construct via :meth:`from_workspace` (resolve from the workspace + cwd)
|
|
150
|
+
or :meth:`at_paths` (pin both files explicitly, as the tests do).
|
|
151
|
+
Internally the two tiers are held in memory and refreshed from disk on
|
|
152
|
+
construction and on :meth:`reload`; :meth:`set` stages a change into the
|
|
153
|
+
in-memory project tier and :meth:`save` flushes it.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self, locations: PreferenceLocations) -> None:
|
|
157
|
+
self._locations = locations
|
|
158
|
+
self._global_tier = _load_tier(locations.global_path)
|
|
159
|
+
self._project_tier = _load_tier(locations.project_path)
|
|
160
|
+
|
|
161
|
+
@classmethod
|
|
162
|
+
def at_paths(
|
|
163
|
+
cls,
|
|
164
|
+
global_path: str | os.PathLike[str],
|
|
165
|
+
project_path: str | os.PathLike[str],
|
|
166
|
+
) -> PreferenceStore:
|
|
167
|
+
"""Build a store whose two tiers are pinned to explicit paths.
|
|
168
|
+
|
|
169
|
+
The direct seam used by tests: point both files at a temp dir and the
|
|
170
|
+
real home is never touched. Neither file needs to exist yet.
|
|
171
|
+
"""
|
|
172
|
+
return cls(
|
|
173
|
+
PreferenceLocations(
|
|
174
|
+
global_path=Path(global_path),
|
|
175
|
+
project_path=Path(project_path),
|
|
176
|
+
)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
@classmethod
|
|
180
|
+
def from_workspace(
|
|
181
|
+
cls,
|
|
182
|
+
workspace: Workspace,
|
|
183
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
184
|
+
) -> PreferenceStore:
|
|
185
|
+
"""Build a store from a resolved workspace (global tier) and a
|
|
186
|
+
working directory (project tier).
|
|
187
|
+
|
|
188
|
+
The global file is ``workspace.settings_path``; the project file is
|
|
189
|
+
``<cwd>/<brand profile dir>/settings.json`` (``.pindusagi``), so a
|
|
190
|
+
checkout carries its own overrides next to its source. ``cwd``
|
|
191
|
+
defaults to the process working directory.
|
|
192
|
+
"""
|
|
193
|
+
base = Path(cwd) if cwd is not None else Path.cwd()
|
|
194
|
+
return cls(
|
|
195
|
+
PreferenceLocations(
|
|
196
|
+
global_path=workspace.settings_path,
|
|
197
|
+
project_path=base / BRAND.profile_dir_name / "settings.json",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def paths(self) -> PreferenceLocations:
|
|
202
|
+
"""The two resolved file locations this store reads and writes."""
|
|
203
|
+
return self._locations
|
|
204
|
+
|
|
205
|
+
def reload(self) -> None:
|
|
206
|
+
"""Re-read both tiers from disk, discarding any unsaved staged change."""
|
|
207
|
+
self._global_tier = _load_tier(self._locations.global_path)
|
|
208
|
+
self._project_tier = _load_tier(self._locations.project_path)
|
|
209
|
+
|
|
210
|
+
def get(self, key: str) -> Any:
|
|
211
|
+
"""Read one preference, resolved across the tiers.
|
|
212
|
+
|
|
213
|
+
Precedence is project → global → :data:`DEFAULT_PREFERENCES`: the
|
|
214
|
+
first tier that defines the key wins. ``key`` may be the snake_case
|
|
215
|
+
field name or the camelCase JSON spelling; an unknown key raises
|
|
216
|
+
:class:`KeyError`. The result is always a concrete value, so callers
|
|
217
|
+
never branch on ``None``.
|
|
218
|
+
"""
|
|
219
|
+
field = canonical_key(key)
|
|
220
|
+
if field in self._project_tier:
|
|
221
|
+
return self._project_tier[field]
|
|
222
|
+
if field in self._global_tier:
|
|
223
|
+
return self._global_tier[field]
|
|
224
|
+
return getattr(DEFAULT_PREFERENCES, field)
|
|
225
|
+
|
|
226
|
+
def set(self, key: str, value: Any) -> None:
|
|
227
|
+
"""Stage a preference into the in-memory **project** tier.
|
|
228
|
+
|
|
229
|
+
Does not write to disk on its own — call :meth:`save` to persist.
|
|
230
|
+
Setting a key to ``None`` clears the project-tier override for it,
|
|
231
|
+
letting the value fall back to the global tier or the default on the
|
|
232
|
+
next read (the TS ``undefined``-clears semantics).
|
|
233
|
+
"""
|
|
234
|
+
field = canonical_key(key)
|
|
235
|
+
if value is None:
|
|
236
|
+
self._project_tier.pop(field, None)
|
|
237
|
+
return
|
|
238
|
+
self._project_tier[field] = _freeze(value)
|
|
239
|
+
|
|
240
|
+
def snapshot(self) -> Preferences:
|
|
241
|
+
"""The fully-resolved snapshot: defaults under global under project."""
|
|
242
|
+
merged: dict[str, Any] = {
|
|
243
|
+
field: getattr(DEFAULT_PREFERENCES, field) for field in SETTING_KEYS
|
|
244
|
+
}
|
|
245
|
+
for tier in (self._global_tier, self._project_tier):
|
|
246
|
+
for field in SETTING_KEYS:
|
|
247
|
+
if field in tier:
|
|
248
|
+
merged[field] = tier[field]
|
|
249
|
+
return Preferences(**merged)
|
|
250
|
+
|
|
251
|
+
def project_overrides(self) -> dict[str, Any]:
|
|
252
|
+
"""The raw in-memory project tier (the keys a checkout overrides),
|
|
253
|
+
as a copy keyed by canonical field name."""
|
|
254
|
+
return dict(self._project_tier)
|
|
255
|
+
|
|
256
|
+
def global_defaults(self) -> dict[str, Any]:
|
|
257
|
+
"""The raw in-memory global tier (machine-wide keys), as a copy
|
|
258
|
+
keyed by canonical field name."""
|
|
259
|
+
return dict(self._global_tier)
|
|
260
|
+
|
|
261
|
+
def save(self) -> None:
|
|
262
|
+
"""Persist the staged in-memory **project** tier to its file.
|
|
263
|
+
|
|
264
|
+
Writes the project tier only; the global file is never rewritten
|
|
265
|
+
here, so a per-checkout :meth:`set` cannot mutate machine-wide
|
|
266
|
+
preferences. Creates the containing directory if it is missing.
|
|
267
|
+
"""
|
|
268
|
+
_write_tier(self._locations.project_path, self._project_tier)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Transcript-export subsystem — public barrel.
|
|
2
|
+
|
|
3
|
+
The HTML transcript publisher and its supporting machinery: the table-driven
|
|
4
|
+
SGR painter (:func:`paint_sgr`) that turns terminal-styled output into safe
|
|
5
|
+
HTML spans, the :class:`ThemeBridge` that computes export colors from an
|
|
6
|
+
:class:`ExportTheme` via a WCAG luminance LUT, the regenerated page-shell
|
|
7
|
+
:data:`PAGE_SHELL` template with its ``{{TOKEN}}`` slots and :func:`fill`
|
|
8
|
+
helper, and :func:`publish_transcript` which renders a session transcript to
|
|
9
|
+
a standalone HTML document using markdown-it-py, Pygments, and the painter.
|
|
10
|
+
|
|
11
|
+
Port note: the TS build rendered with ``marked`` + ``highlight.js`` and kept
|
|
12
|
+
this subsystem's shared types on the briefing contract; the Python build
|
|
13
|
+
renders with **markdown-it-py** + **Pygments** (license notices in the
|
|
14
|
+
emitted page swapped accordingly) and owns its types locally in
|
|
15
|
+
``transcript_export/contract.py`` (only :class:`BriefingFault` stays
|
|
16
|
+
briefing-owned).
|
|
17
|
+
|
|
18
|
+
Consumers import the publish surface from ``induscode.transcript_export``
|
|
19
|
+
rather than reaching into individual modules.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .contract import (
|
|
23
|
+
FALLBACK_EXPORT_THEME,
|
|
24
|
+
SGR_INITIAL_STATE,
|
|
25
|
+
SHELL_SLOTS,
|
|
26
|
+
BriefingFault,
|
|
27
|
+
BriefingFaultKind,
|
|
28
|
+
ExportTheme,
|
|
29
|
+
ImageContent,
|
|
30
|
+
LuminanceLut,
|
|
31
|
+
MessagePart,
|
|
32
|
+
PublishEntry,
|
|
33
|
+
PublishMessage,
|
|
34
|
+
PublishOptions,
|
|
35
|
+
PublishRole,
|
|
36
|
+
Rgb,
|
|
37
|
+
SgrCommandToken,
|
|
38
|
+
SgrMutation,
|
|
39
|
+
SgrState,
|
|
40
|
+
SgrTextToken,
|
|
41
|
+
SgrToken,
|
|
42
|
+
ShellSlot,
|
|
43
|
+
TextContent,
|
|
44
|
+
ThemeBridge,
|
|
45
|
+
ThemeMode,
|
|
46
|
+
ThinkingPart,
|
|
47
|
+
ToolCallPart,
|
|
48
|
+
TranscriptPart,
|
|
49
|
+
WidgetRender,
|
|
50
|
+
briefing_fault,
|
|
51
|
+
)
|
|
52
|
+
from .publish import HIGHLIGHT_LICENSE, MARKDOWN_LICENSE, publish_transcript
|
|
53
|
+
from .sgr import SGR_CODE_TABLE, fold_sgr, paint_sgr, tokenize_sgr
|
|
54
|
+
from .template import CLIENT_SCRIPT, PAGE_SHELL, PAGE_STYLES, SlotValues, fill
|
|
55
|
+
from .theme_bridge import (
|
|
56
|
+
DefaultThemeBridge,
|
|
57
|
+
build_luminance_lut,
|
|
58
|
+
create_theme_bridge,
|
|
59
|
+
format_color,
|
|
60
|
+
parse_color,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"BriefingFault",
|
|
65
|
+
"BriefingFaultKind",
|
|
66
|
+
"CLIENT_SCRIPT",
|
|
67
|
+
"DefaultThemeBridge",
|
|
68
|
+
"ExportTheme",
|
|
69
|
+
"FALLBACK_EXPORT_THEME",
|
|
70
|
+
"HIGHLIGHT_LICENSE",
|
|
71
|
+
"ImageContent",
|
|
72
|
+
"LuminanceLut",
|
|
73
|
+
"MARKDOWN_LICENSE",
|
|
74
|
+
"MessagePart",
|
|
75
|
+
"PAGE_SHELL",
|
|
76
|
+
"PAGE_STYLES",
|
|
77
|
+
"PublishEntry",
|
|
78
|
+
"PublishMessage",
|
|
79
|
+
"PublishOptions",
|
|
80
|
+
"PublishRole",
|
|
81
|
+
"Rgb",
|
|
82
|
+
"SGR_CODE_TABLE",
|
|
83
|
+
"SGR_INITIAL_STATE",
|
|
84
|
+
"SHELL_SLOTS",
|
|
85
|
+
"SgrCommandToken",
|
|
86
|
+
"SgrMutation",
|
|
87
|
+
"SgrState",
|
|
88
|
+
"SgrTextToken",
|
|
89
|
+
"SgrToken",
|
|
90
|
+
"ShellSlot",
|
|
91
|
+
"SlotValues",
|
|
92
|
+
"TextContent",
|
|
93
|
+
"ThemeBridge",
|
|
94
|
+
"ThemeMode",
|
|
95
|
+
"ThinkingPart",
|
|
96
|
+
"ToolCallPart",
|
|
97
|
+
"TranscriptPart",
|
|
98
|
+
"WidgetRender",
|
|
99
|
+
"briefing_fault",
|
|
100
|
+
"build_luminance_lut",
|
|
101
|
+
"create_theme_bridge",
|
|
102
|
+
"fill",
|
|
103
|
+
"fold_sgr",
|
|
104
|
+
"format_color",
|
|
105
|
+
"paint_sgr",
|
|
106
|
+
"parse_color",
|
|
107
|
+
"publish_transcript",
|
|
108
|
+
"tokenize_sgr",
|
|
109
|
+
]
|