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,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
|
+
]
|