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,469 @@
1
+ """Composer autocomplete — slash + ``@``/path completion providers.
2
+
3
+ Port of TS ``src/console/input/complete.ts`` in two layers:
4
+
5
+ 1. **The pure core** — :func:`complete_at` / :func:`apply_suggestion` and
6
+ the :class:`Suggestion` / :class:`TokenSpan` / :class:`CompletionResult`
7
+ shapes, ported verbatim (whitespace-delimited active token, slash-only-
8
+ at-buffer-start routing, ``@`` sigil stripping/re-application, dirs-first
9
+ sort, trailing-slash descent). The slash branch reads the M1 registry
10
+ through :func:`induscode.console_slash.match_prefix`; the path branch
11
+ lists through an injected :data:`~induscode.console.input.dir_reader.DirReader`
12
+ so the decision logic stays I/O-free and unit-testable.
13
+
14
+ 2. **The protocol adapters** — :class:`SlashCommandProvider`,
15
+ :class:`PathCompletionProvider`, and the :class:`ConsoleAutocompleteProvider`
16
+ router, implementing the framework's
17
+ :class:`indusagi.tui.autocomplete.AutocompleteProvider` protocol
18
+ (``async get_suggestions(lines, cursor_line, cursor_col)`` →
19
+ ``SuggestionResult | None``; ``apply_completion(...)`` → ``ApplyResult``)
20
+ so the wave-3 console hands one provider straight to the framework
21
+ ``PromptEditor``. ``apply_completion`` keeps the **TS splice semantics**
22
+ — the whole active-token span ``[start, end)`` is replaced (not just the
23
+ prefix before the caret, which is what the framework's own apply
24
+ strategy does) and the caret lands after the inserted value, so
25
+ accepting a directory (value ending ``/``) immediately re-offers the
26
+ next level on the editor's refresh.
27
+
28
+ Mapping notes: :class:`Suggestion` → ``AutocompleteItem`` via
29
+ :func:`to_autocomplete_item` (``detail`` → ``description``; ``is_dir`` is
30
+ encoded by the trailing ``/`` on ``value``/``label``, the framework
31
+ convention). ``SuggestionResult.prefix`` is the active token's stem (the
32
+ text up to the caret), which keeps ``PromptEditor``'s best-match highlight
33
+ and its ``prefix.startswith("/")`` slash-submit chaining working unchanged.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import asyncio
39
+ from dataclasses import dataclass
40
+ from typing import Literal, Sequence, TypeAlias
41
+
42
+ from indusagi.tui.autocomplete import (
43
+ ApplyResult,
44
+ AutocompleteItem,
45
+ SuggestionResult,
46
+ )
47
+
48
+ from induscode.console_slash import SlashRegistry, match_prefix, tokens_of
49
+
50
+ from .dir_reader import DirReader
51
+
52
+ __all__ = [
53
+ "AppliedSuggestion",
54
+ "CompletionKind",
55
+ "CompletionResult",
56
+ "ConsoleAutocompleteProvider",
57
+ "PathCompletionProvider",
58
+ "SlashCommandProvider",
59
+ "Suggestion",
60
+ "TokenSpan",
61
+ "active_token",
62
+ "apply_suggestion",
63
+ "complete_at",
64
+ "to_autocomplete_item",
65
+ ]
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Suggestion shape (TS — verbatim)
70
+ # ---------------------------------------------------------------------------
71
+
72
+ #: Which source produced a batch of suggestions.
73
+ CompletionKind: TypeAlias = Literal["slash", "path", "none"]
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class Suggestion:
78
+ """One structured completion candidate.
79
+
80
+ ``value`` is the text spliced in when this suggestion is accepted
81
+ (already including any leading sigil — ``/`` for a command, ``@`` /
82
+ trailing slash for a path). ``label`` is what the completion window
83
+ shows; ``detail`` is the one-line description (a command summary, or a
84
+ ``directory``/``file`` hint). ``is_dir`` marks a directory so the
85
+ surface can keep the window open for further descent.
86
+ """
87
+
88
+ # The text spliced into the buffer when accepted.
89
+ value: str
90
+ # The primary text shown in the completion window.
91
+ label: str
92
+ # A one-line description shown beside the label.
93
+ detail: str
94
+ # Whether this path suggestion is a directory (False for slash commands).
95
+ is_dir: bool
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class TokenSpan:
100
+ """The half-open ``[start, end)`` span of the buffer the active token
101
+ occupies. The surface replaces exactly this span when a suggestion is
102
+ applied, so the sigil and any preceding text are preserved."""
103
+
104
+ # Inclusive start offset of the active token.
105
+ start: int
106
+ # Exclusive end offset of the active token.
107
+ end: int
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class CompletionResult:
112
+ """The full result of asking for completion at a caret position.
113
+
114
+ ``kind`` reports which source answered (``none`` when nothing applies),
115
+ ``span`` is the token to replace, and ``suggestions`` is the ranked
116
+ candidate list (empty when nothing matched).
117
+ """
118
+
119
+ # Which source produced the suggestions.
120
+ kind: CompletionKind
121
+ # The buffer span the active token occupies.
122
+ span: TokenSpan
123
+ # The ranked candidate list (possibly empty).
124
+ suggestions: tuple[Suggestion, ...]
125
+
126
+
127
+ @dataclass(frozen=True, slots=True)
128
+ class AppliedSuggestion:
129
+ """The buffer rewritten by :func:`apply_suggestion`, plus the caret
130
+ placed at the end of the inserted value."""
131
+
132
+ buffer: str
133
+ caret: int
134
+
135
+
136
+ def _no_completion(caret: int) -> CompletionResult:
137
+ """The inert result when no completion applies at the caret."""
138
+ return CompletionResult(kind="none", span=TokenSpan(caret, caret), suggestions=())
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Active-token extraction
143
+ # ---------------------------------------------------------------------------
144
+
145
+ #: Characters that terminate the active completion token.
146
+ _BOUNDARY = frozenset({" ", "\t", "\n"})
147
+
148
+
149
+ def active_token(buffer: str, caret: int) -> tuple[TokenSpan, str]:
150
+ """Find the whitespace-delimited token the caret sits within or at the
151
+ end of.
152
+
153
+ Pure: walks left from the caret to the previous boundary and right to
154
+ the next boundary, returning the enclosing span and the token's text up
155
+ to the caret (the ``stem``) — so completing mid-token replaces only the
156
+ prefix the user has typed.
157
+
158
+ :param buffer: the composer text
159
+ :param caret: the caret offset within the buffer
160
+ """
161
+ start = caret
162
+ while start > 0 and buffer[start - 1] not in _BOUNDARY:
163
+ start -= 1
164
+ end = caret
165
+ while end < len(buffer) and buffer[end] not in _BOUNDARY:
166
+ end += 1
167
+ return TokenSpan(start=start, end=end), buffer[start:caret]
168
+
169
+
170
+ # ---------------------------------------------------------------------------
171
+ # Slash completion
172
+ # ---------------------------------------------------------------------------
173
+
174
+
175
+ def _complete_slash(stem: str, registry: SlashRegistry) -> tuple[Suggestion, ...]:
176
+ """Offer slash-command suggestions for a ``/``-prefixed stem.
177
+
178
+ Pure over the registry: the candidate set comes from
179
+ :func:`~induscode.console_slash.match_prefix` (case-insensitive prefix
180
+ over every token, registry order), then the TS ranking is applied — the
181
+ *first* matching token per command decides, and exact token matches
182
+ float to the front so the fully-typed command is always the default.
183
+ Each suggestion's ``value`` carries the leading slash back, and a
184
+ trailing space when the command takes args (so the caret lands ready
185
+ for input).
186
+ """
187
+ needle = stem[1:].lower()
188
+ exact: list[Suggestion] = []
189
+ prefix_hits: list[Suggestion] = []
190
+ for command in match_prefix(registry, stem[1:]):
191
+ hit = next(
192
+ (token for token in tokens_of(command) if token.lower().startswith(needle)),
193
+ None,
194
+ )
195
+ if hit is None: # pragma: no cover - match_prefix guarantees a hit
196
+ continue
197
+ value = f"/{command.name} " if command.takes_args else f"/{command.name}"
198
+ suggestion = Suggestion(
199
+ value=value,
200
+ label=f"/{command.name}",
201
+ detail=command.summary,
202
+ is_dir=False,
203
+ )
204
+ if hit.lower() == needle:
205
+ exact.append(suggestion)
206
+ else:
207
+ prefix_hits.append(suggestion)
208
+ return (*exact, *prefix_hits)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Path completion
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _split_path_stem(stem: str) -> tuple[str, str, str]:
217
+ """Split a path stem into the directory to list, the leaf prefix to
218
+ match, and the (possibly empty) ``@`` sigil.
219
+
220
+ For ``src/con`` the directory is ``src`` and the leaf is ``con``; for a
221
+ bare ``con`` the directory is ``.`` (the working dir); a trailing
222
+ separator (``src/``) lists ``src`` with an empty leaf. The sigil is
223
+ stripped first and re-applied by the caller on the produced ``value``.
224
+ """
225
+ sigil = "@" if stem.startswith("@") else ""
226
+ body = stem[len(sigil) :]
227
+ slash = body.rfind("/")
228
+ if slash < 0:
229
+ return ".", body, sigil
230
+ return body[:slash] or ".", body[slash + 1 :], sigil
231
+
232
+
233
+ def _complete_path(stem: str, read_dir: DirReader) -> tuple[Suggestion, ...]:
234
+ """Offer file-path suggestions for a path-like stem.
235
+
236
+ Pure over the injected :data:`DirReader`: lists the resolved directory,
237
+ keeps entries whose leaf name starts with the typed prefix
238
+ (case-insensitively), sorts directories before files and then
239
+ alphabetically, and rebuilds each ``value`` as the full stem with the
240
+ matched leaf substituted. A directory suggestion appends a trailing
241
+ slash so the next accept descends into it.
242
+ """
243
+ directory, leaf, sigil = _split_path_stem(stem)
244
+ lower_leaf = leaf.lower()
245
+ prefix = "" if directory == "." else f"{directory}/"
246
+ matches = sorted(
247
+ (entry for entry in read_dir(directory) if entry.name.lower().startswith(lower_leaf)),
248
+ key=lambda entry: (not entry.is_dir, entry.name),
249
+ )
250
+ return tuple(
251
+ Suggestion(
252
+ value=f"{sigil}{prefix}{entry.name}{'/' if entry.is_dir else ''}",
253
+ label=f"{entry.name}{'/' if entry.is_dir else ''}",
254
+ detail="directory" if entry.is_dir else "file",
255
+ is_dir=entry.is_dir,
256
+ )
257
+ for entry in matches
258
+ )
259
+
260
+
261
+ def _looks_like_path(stem: str) -> bool:
262
+ """Whether a stem should be routed to path completion: it carries the
263
+ ``@`` attachment sigil or already contains a path separator. A bare word
264
+ is left alone so plain prose never triggers a directory scan."""
265
+ return stem.startswith("@") or "/" in stem
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Entry points (pure core)
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ def complete_at(
274
+ buffer: str,
275
+ caret: int,
276
+ registry: SlashRegistry,
277
+ read_dir: DirReader,
278
+ ) -> CompletionResult:
279
+ """Compute the completion offering at a caret position.
280
+
281
+ Routes on the active token's stem: a ``/`` at buffer-start yields slash
282
+ completion (a slash mid-prose is a path separator or literal, not a
283
+ command); a ``@``/path-like stem yields path completion; anything else
284
+ yields the inert ``none``. Pure with respect to both sources — the
285
+ registry is data and the dir-reader is injected.
286
+
287
+ :param buffer: the composer text
288
+ :param caret: the caret offset within the buffer
289
+ :param registry: the slash-command registry to match against
290
+ :param read_dir: the directory reader the path branch lists through
291
+ """
292
+ span, stem = active_token(buffer, caret)
293
+
294
+ if stem.startswith("/") and span.start == 0:
295
+ return CompletionResult(kind="slash", span=span, suggestions=_complete_slash(stem, registry))
296
+
297
+ if _looks_like_path(stem):
298
+ return CompletionResult(kind="path", span=span, suggestions=_complete_path(stem, read_dir))
299
+
300
+ return _no_completion(caret)
301
+
302
+
303
+ def apply_suggestion(
304
+ buffer: str, span: TokenSpan, suggestion: Suggestion
305
+ ) -> AppliedSuggestion:
306
+ """Splice an accepted suggestion back into the buffer.
307
+
308
+ Pure: replaces the active token's span with the suggestion's ``value``
309
+ and returns the rewritten buffer plus the caret placed at the end of the
310
+ inserted value. The surface re-runs :func:`complete_at` afterward so
311
+ descending into a directory (whose value ends in ``/``) immediately
312
+ offers the next level.
313
+
314
+ :param buffer: the composer text the completion was computed against
315
+ :param span: the token span returned by :func:`complete_at`
316
+ :param suggestion: the candidate the user accepted
317
+ """
318
+ rebuilt = buffer[: span.start] + suggestion.value + buffer[span.end :]
319
+ return AppliedSuggestion(buffer=rebuilt, caret=span.start + len(suggestion.value))
320
+
321
+
322
+ def to_autocomplete_item(suggestion: Suggestion) -> AutocompleteItem:
323
+ """Project a console :class:`Suggestion` onto the framework
324
+ ``AutocompleteItem`` (``detail`` → ``description``; ``is_dir`` rides on
325
+ the trailing ``/`` of ``value``/``label``)."""
326
+ return AutocompleteItem(
327
+ value=suggestion.value,
328
+ label=suggestion.label,
329
+ description=suggestion.detail,
330
+ )
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Framework-protocol providers
335
+ # ---------------------------------------------------------------------------
336
+
337
+
338
+ def _line_at(lines: Sequence[str], index: int) -> str:
339
+ """``lines[i] || ""`` parity (out-of-range reads yield the empty string)."""
340
+ if 0 <= index < len(lines):
341
+ return lines[index] or ""
342
+ return ""
343
+
344
+
345
+ def _apply_token_splice(
346
+ lines: Sequence[str], cursor_line: int, cursor_col: int, item: AutocompleteItem
347
+ ) -> ApplyResult:
348
+ """Commit an accepted item with the TS splice semantics: re-derive the
349
+ active token span on the current line, replace the whole span with the
350
+ item's value, and land the caret after it."""
351
+ line = _line_at(lines, cursor_line)
352
+ span, _stem = active_token(line, cursor_col)
353
+ rebuilt = line[: span.start] + item.value + line[span.end :]
354
+ new_lines = list(lines)
355
+ while len(new_lines) <= cursor_line:
356
+ new_lines.append("")
357
+ new_lines[cursor_line] = rebuilt
358
+ return ApplyResult(
359
+ lines=new_lines,
360
+ cursor_line=cursor_line,
361
+ cursor_col=span.start + len(item.value),
362
+ )
363
+
364
+
365
+ def _to_result(suggestions: tuple[Suggestion, ...], stem: str) -> SuggestionResult | None:
366
+ if not suggestions:
367
+ return None
368
+ return SuggestionResult(
369
+ items=[to_autocomplete_item(suggestion) for suggestion in suggestions],
370
+ prefix=stem,
371
+ )
372
+
373
+
374
+ def _is_slash_stem(cursor_line: int, span: TokenSpan, stem: str) -> bool:
375
+ """The TS buffer-start guard in the framework's line/col coordinates:
376
+ the slash must open the very first line."""
377
+ return cursor_line == 0 and span.start == 0 and stem.startswith("/")
378
+
379
+
380
+ class SlashCommandProvider:
381
+ """``AutocompleteProvider`` over the slash registry (the TS slash
382
+ branch). Answers only when the active token is a ``/``-stem opening the
383
+ buffer; returns ``None`` everywhere else so a chained/path provider can
384
+ take over."""
385
+
386
+ def __init__(self, registry: SlashRegistry) -> None:
387
+ self._registry = registry
388
+
389
+ async def get_suggestions(
390
+ self, lines: list[str], cursor_line: int, cursor_col: int
391
+ ) -> SuggestionResult | None:
392
+ line = _line_at(lines, cursor_line)
393
+ span, stem = active_token(line, cursor_col)
394
+ if not _is_slash_stem(cursor_line, span, stem):
395
+ return None
396
+ return _to_result(_complete_slash(stem, self._registry), stem)
397
+
398
+ def apply_completion(
399
+ self,
400
+ lines: list[str],
401
+ cursor_line: int,
402
+ cursor_col: int,
403
+ item: AutocompleteItem,
404
+ prefix: str,
405
+ ) -> ApplyResult:
406
+ return _apply_token_splice(lines, cursor_line, cursor_col, item)
407
+
408
+
409
+ class PathCompletionProvider:
410
+ """``AutocompleteProvider`` over an injected :data:`DirReader` (the TS
411
+ path branch). Answers only for ``@``-sigil or separator-bearing stems;
412
+ the directory listing runs on a worker thread so the UI never stalls
413
+ (the framework's fs idiom)."""
414
+
415
+ def __init__(self, read_dir: DirReader) -> None:
416
+ self._read_dir = read_dir
417
+
418
+ async def get_suggestions(
419
+ self, lines: list[str], cursor_line: int, cursor_col: int
420
+ ) -> SuggestionResult | None:
421
+ line = _line_at(lines, cursor_line)
422
+ _span, stem = active_token(line, cursor_col)
423
+ if not _looks_like_path(stem):
424
+ return None
425
+ suggestions = await asyncio.to_thread(_complete_path, stem, self._read_dir)
426
+ return _to_result(suggestions, stem)
427
+
428
+ def apply_completion(
429
+ self,
430
+ lines: list[str],
431
+ cursor_line: int,
432
+ cursor_col: int,
433
+ item: AutocompleteItem,
434
+ prefix: str,
435
+ ) -> ApplyResult:
436
+ return _apply_token_splice(lines, cursor_line, cursor_col, item)
437
+
438
+
439
+ class ConsoleAutocompleteProvider:
440
+ """The :func:`complete_at` router as one framework provider: a slash
441
+ stem at buffer start wins (even a path-looking one like
442
+ ``/usr/local/bin`` — the TS routing, verbatim), then ``@``/path stems,
443
+ else nothing. This is the provider the wave-3 console hands to
444
+ ``PromptEditor``."""
445
+
446
+ def __init__(self, registry: SlashRegistry, read_dir: DirReader) -> None:
447
+ self._slash = SlashCommandProvider(registry)
448
+ self._path = PathCompletionProvider(read_dir)
449
+
450
+ async def get_suggestions(
451
+ self, lines: list[str], cursor_line: int, cursor_col: int
452
+ ) -> SuggestionResult | None:
453
+ line = _line_at(lines, cursor_line)
454
+ span, stem = active_token(line, cursor_col)
455
+ if _is_slash_stem(cursor_line, span, stem):
456
+ return await self._slash.get_suggestions(lines, cursor_line, cursor_col)
457
+ if _looks_like_path(stem):
458
+ return await self._path.get_suggestions(lines, cursor_line, cursor_col)
459
+ return None
460
+
461
+ def apply_completion(
462
+ self,
463
+ lines: list[str],
464
+ cursor_line: int,
465
+ cursor_col: int,
466
+ item: AutocompleteItem,
467
+ prefix: str,
468
+ ) -> ApplyResult:
469
+ return _apply_token_splice(lines, cursor_line, cursor_col, item)
@@ -0,0 +1,137 @@
1
+ """Console mount — run the interactive surface and resolve the exit code.
2
+
3
+ Port of TS ``src/console/mount.ts`` onto the framework's
4
+ ``mount_interactive`` pattern (``indusagi.ui_bridge.app``): the single entry
5
+ point a run mode calls to take over the terminal. It resolves the colour
6
+ scheme (an explicit override, else the ``colourScheme`` preference, else the
7
+ default), pulls the matching pre-built :class:`~induscode.console.contract
8
+ .ConsoleTheme` bundle (whose Textual Theme the app registers at mount — all
9
+ four schemes are registered so the picker's live preview is a native
10
+ retheme), assembles the slash registry (the built default catalog unless one
11
+ is injected), constructs the :class:`~induscode.console.app.ConsoleApp`, and
12
+ awaits ``App.run_async()``.
13
+
14
+ Exit transcript: the TS Ink surface drew inline, so the conversation stayed
15
+ in terminal scrollback after exit for free; Textual's alternate screen is
16
+ erased when the app leaves. Once ``run_async`` returns (normal screen
17
+ restored), the session transcript — the same content the ``MessageList``
18
+ held — is re-rendered as plain Rich text and printed so the conversation
19
+ survives in scrollback. ``transcript_file`` injects the destination (tests;
20
+ default stdout); an empty session prints nothing at all.
21
+
22
+ This module performs the only Textual mount in the console subsystem;
23
+ everything it composes (the reducer, the theme engine, the slash registry,
24
+ the input modules, the chrome widgets, the overlay flows) is pure or
25
+ presentational, so the rest of the package stays testable without a running
26
+ app. The repl runner reaches this mount through its injectable
27
+ ``set_console_mount`` seam (see ``induscode.boot.runners.repl_runner``).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from typing import IO, Callable
33
+
34
+ from rich.console import Console
35
+ from textual.app import AutopilotCallbackType
36
+
37
+ from induscode.console_slash import SlashRegistry
38
+
39
+ from .app import ConsoleApp
40
+ from .contract import OverlayServices, SessionConductor, is_theme_scheme
41
+ from .theme import resolve_theme
42
+
43
+ __all__ = [
44
+ "mount_console",
45
+ ]
46
+
47
+
48
+ def _resolve_scheme(scheme: str | None, services: OverlayServices | None) -> str | None:
49
+ """The scheme to mount with: an explicit override wins; otherwise the
50
+ ``colourScheme`` preference (when readable and recognised); otherwise
51
+ ``None`` so :func:`~induscode.console.theme.resolve_theme` falls back to
52
+ the default scheme. A corrupt preference never blanks the console."""
53
+ if scheme is not None:
54
+ return scheme
55
+ if services is None:
56
+ return None
57
+ try:
58
+ candidate = services.settings.get("colourScheme")
59
+ except Exception:
60
+ return None
61
+ if isinstance(candidate, str) and is_theme_scheme(candidate):
62
+ return candidate
63
+ return None
64
+
65
+
66
+ def _default_registry() -> SlashRegistry:
67
+ """The built default slash catalog (assembled lazily so importing the
68
+ mount never triggers a filesystem discovery scan)."""
69
+ from .slash_commands import build_default_registry
70
+
71
+ return build_default_registry()
72
+
73
+
74
+ async def mount_console(
75
+ conductor: SessionConductor,
76
+ services: OverlayServices | None = None,
77
+ *,
78
+ scheme: str | None = None,
79
+ slash: SlashRegistry | None = None,
80
+ initial_input: str | None = None,
81
+ verbose: bool = False,
82
+ on_exit: Callable[[], None] | None = None,
83
+ cwd: str | None = None,
84
+ headless: bool = False,
85
+ auto_pilot: AutopilotCallbackType | None = None,
86
+ transcript_file: IO[str] | None = None,
87
+ ) -> int:
88
+ """Render the interactive console for a session and resolve the process
89
+ exit code once the surface is dismissed.
90
+
91
+ Port of the TS ``mountConsole(conductor, opts)``: the options bag becomes
92
+ keyword arguments and Ink's ``render`` + ``waitUntilExit`` collapse into
93
+ ``App.run_async()``. Everything is optional with a sensible default: the
94
+ theme falls back to the preference / default scheme, the slash registry
95
+ to the built-in catalog, the seeds to empty. The conductor is the only
96
+ required input and is passed positionally; ``services`` carries the
97
+ runtime handles the modal overlays drive (absent on headless paths — the
98
+ overlays then settle inert).
99
+
100
+ ``headless`` / ``auto_pilot`` are forwarded to ``App.run_async`` so tests
101
+ can drive this real mount path under a ``Pilot`` without a TTY; both
102
+ default to the live-terminal behaviour.
103
+
104
+ :param conductor: the session this console drives
105
+ :param services: the overlay service bundle (settings, sessions, vault…)
106
+ :param scheme: an explicit colour-scheme override
107
+ :param slash: the slash registry to dispatch against
108
+ :param initial_input: an optional first user turn submitted on mount
109
+ :param verbose: whether to render verbose diagnostics in the banner
110
+ :param on_exit: invoked when the console asks the host process to exit
111
+ :param cwd: the workspace directory (defaults to the process cwd)
112
+ :param headless: run without a real terminal (tests)
113
+ :param auto_pilot: a Textual autopilot callback (tests)
114
+ :param transcript_file: where the exit transcript is printed (stdout)
115
+ :returns: the process exit code (``0`` on a clean interactive leave)
116
+ """
117
+ theme = resolve_theme(_resolve_scheme(scheme, services))
118
+ registry = slash if slash is not None else _default_registry()
119
+
120
+ app = ConsoleApp(
121
+ conductor,
122
+ theme=theme,
123
+ slash=registry,
124
+ services=services,
125
+ initial_input=initial_input,
126
+ verbose=verbose,
127
+ on_exit=on_exit,
128
+ cwd=cwd,
129
+ )
130
+ result = await app.run_async(headless=headless, auto_pilot=auto_pilot)
131
+
132
+ transcript = app.exit_transcript()
133
+ if transcript is not None:
134
+ console = Console() if transcript_file is None else Console(file=transcript_file)
135
+ console.print(transcript)
136
+
137
+ return 0 if result is None else int(result)
@@ -0,0 +1,94 @@
1
+ """Overlays subsystem — public barrel (port of TS ``src/console/overlays``).
2
+
3
+ The TS barrel exported the ``OverlayHost`` mount point plus three
4
+ always-mounted group components; under the Python dialog-API inversion
5
+ (``ModalScreen[Result]`` dismissal instead of callback props — port plan
6
+ analysis 02, risk 1) the host becomes :func:`open_overlay` — an awaited
7
+ ``push_screen_wait`` flow per :data:`~induscode.console.contract.ModalKind`
8
+ returning a typed :class:`OverlayOutcome` — and the groups become the flow
9
+ modules behind it (:mod:`.pickers`, :mod:`.sessions`, :mod:`.auth`). The
10
+ console App imports the router from here rather than reaching into the
11
+ individual modules; the pure mapping helpers are re-exported for tests and
12
+ for the wave-3 ``ConsoleApp`` wiring.
13
+ """
14
+
15
+ from .auth import (
16
+ PluginOverlayScreen,
17
+ PluginRequest,
18
+ login_provider_rows,
19
+ read_entry_mode,
20
+ read_plugin_request,
21
+ read_provider_id,
22
+ read_requested_provider,
23
+ run_oauth_flow,
24
+ run_plugin,
25
+ run_sign_in,
26
+ run_sign_out,
27
+ saved_account_rows,
28
+ seed_oauth_state,
29
+ select_provider_model,
30
+ )
31
+ from .pickers import (
32
+ THEME_CHOICES,
33
+ THEME_NAMES,
34
+ TOGGLE_VALUES,
35
+ authenticated_providers,
36
+ build_settings_items,
37
+ card_catalog_id,
38
+ list_model_refs,
39
+ read_scoped_payload,
40
+ ref_to_card,
41
+ run_model_picker,
42
+ run_scoped_models,
43
+ run_settings_picker,
44
+ run_theme_picker,
45
+ )
46
+ from .router import OVERLAY_HANDLERS, OverlayFlow, OverlayOutcome, open_overlay
47
+ from .sessions import (
48
+ run_prior_turns,
49
+ run_session_picker,
50
+ run_tree_navigator,
51
+ to_session_info,
52
+ to_tree_option,
53
+ to_turn_option,
54
+ )
55
+
56
+ __all__ = [
57
+ "OVERLAY_HANDLERS",
58
+ "OverlayFlow",
59
+ "OverlayOutcome",
60
+ "PluginOverlayScreen",
61
+ "PluginRequest",
62
+ "THEME_CHOICES",
63
+ "THEME_NAMES",
64
+ "TOGGLE_VALUES",
65
+ "authenticated_providers",
66
+ "build_settings_items",
67
+ "card_catalog_id",
68
+ "list_model_refs",
69
+ "login_provider_rows",
70
+ "open_overlay",
71
+ "read_entry_mode",
72
+ "read_plugin_request",
73
+ "read_provider_id",
74
+ "read_requested_provider",
75
+ "read_scoped_payload",
76
+ "ref_to_card",
77
+ "run_model_picker",
78
+ "run_oauth_flow",
79
+ "run_plugin",
80
+ "run_prior_turns",
81
+ "run_scoped_models",
82
+ "run_session_picker",
83
+ "run_settings_picker",
84
+ "run_sign_in",
85
+ "run_sign_out",
86
+ "run_theme_picker",
87
+ "run_tree_navigator",
88
+ "saved_account_rows",
89
+ "seed_oauth_state",
90
+ "select_provider_model",
91
+ "to_session_info",
92
+ "to_tree_option",
93
+ "to_turn_option",
94
+ ]