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,107 @@
1
+ """Console input — public barrel for the composer-input modules (M5 wave 1).
2
+
3
+ Port of TS ``src/console/input`` (``keymap.ts`` / ``complete.ts`` /
4
+ ``dir-reader.ts`` / ``index.ts``), reshaped around the framework editor per
5
+ the port plan (analysis 02 §6):
6
+
7
+ - **intents** — the :data:`ConsoleVerb` vocabulary (TS verbatim) split into
8
+ the app-level chord→intent :data:`INTENT_TABLE` (the data the wave-3
9
+ ``ConsoleApp`` BINDINGS derive from) and the :data:`EDITOR_DELEGATED` map
10
+ documenting which TS verbs collapsed into the framework
11
+ ``EditorKeybindingsManager``/``EditorCore``. The TS ``readKey``
12
+ classifier itself is not ported — Textual + the framework key decoder do
13
+ that job.
14
+ - **chord** — the double-tap latch (:func:`advance_chord`: Esc×2,
15
+ Ctrl+U×2) ported verbatim, plus the Ctrl+C exit window
16
+ (:func:`advance_exit_window`) extracted from the TS surface as a pure
17
+ helper with an injectable clock. The app owns the live latch values and
18
+ the timers.
19
+ - **providers** — the slash + ``@``/path completion engine
20
+ (:func:`complete_at` / :func:`apply_suggestion`, pure and verbatim) and
21
+ its framework ``AutocompleteProvider`` adapters
22
+ (:class:`ConsoleAutocompleteProvider` et al.) feeding ``PromptEditor``.
23
+ - **dir_reader** — the live ``os.scandir`` binding of the
24
+ :data:`DirReader` seam.
25
+
26
+ TS ``paste.ts`` is **deleted by design**: the paste vault, the
27
+ ``‹clip N · K lines›`` markers, the burst debounce, and the
28
+ bracketed-paste stripping all collapse into the framework
29
+ ``EditorCore`` paste markers + Textual's native ``events.Paste``
30
+ (pinned in ``tests/console/test_input.py``).
31
+ """
32
+
33
+ from .chord import (
34
+ CHORD_VERBS,
35
+ CHORD_WINDOW_MS,
36
+ CTRL_C_EXIT_WINDOW_MS,
37
+ ChordLatch,
38
+ ChordOutcome,
39
+ ChordStep,
40
+ ExitAction,
41
+ ExitStep,
42
+ ExitWindow,
43
+ NO_CHORD,
44
+ NO_EXIT_WINDOW,
45
+ advance_chord,
46
+ advance_exit_window,
47
+ )
48
+ from .dir_reader import DirEntry, DirReader, create_dir_reader
49
+ from .intents import (
50
+ CONSOLE_VERBS,
51
+ ConsoleIntent,
52
+ ConsoleVerb,
53
+ EDITOR_DELEGATED,
54
+ INTENT_TABLE,
55
+ NO_INTENT,
56
+ )
57
+ from .providers import (
58
+ AppliedSuggestion,
59
+ CompletionKind,
60
+ CompletionResult,
61
+ ConsoleAutocompleteProvider,
62
+ PathCompletionProvider,
63
+ SlashCommandProvider,
64
+ Suggestion,
65
+ TokenSpan,
66
+ active_token,
67
+ apply_suggestion,
68
+ complete_at,
69
+ to_autocomplete_item,
70
+ )
71
+
72
+ __all__ = [
73
+ "AppliedSuggestion",
74
+ "CHORD_VERBS",
75
+ "CHORD_WINDOW_MS",
76
+ "CONSOLE_VERBS",
77
+ "CTRL_C_EXIT_WINDOW_MS",
78
+ "ChordLatch",
79
+ "ChordOutcome",
80
+ "ChordStep",
81
+ "CompletionKind",
82
+ "CompletionResult",
83
+ "ConsoleAutocompleteProvider",
84
+ "ConsoleIntent",
85
+ "ConsoleVerb",
86
+ "DirEntry",
87
+ "DirReader",
88
+ "EDITOR_DELEGATED",
89
+ "ExitAction",
90
+ "ExitStep",
91
+ "ExitWindow",
92
+ "INTENT_TABLE",
93
+ "NO_CHORD",
94
+ "NO_EXIT_WINDOW",
95
+ "NO_INTENT",
96
+ "PathCompletionProvider",
97
+ "SlashCommandProvider",
98
+ "Suggestion",
99
+ "TokenSpan",
100
+ "active_token",
101
+ "advance_chord",
102
+ "advance_exit_window",
103
+ "apply_suggestion",
104
+ "complete_at",
105
+ "create_dir_reader",
106
+ "to_autocomplete_item",
107
+ ]
@@ -0,0 +1,197 @@
1
+ """Chord latching — the stateful layer over the pure intent vocabulary.
2
+
3
+ Two small pure machines the wave-3 ``ConsoleApp`` owns the live values of:
4
+
5
+ 1. **The double-tap latch** (TS ``keymap.ts`` ``advanceChord`` — verbatim).
6
+ A first tap of ``flow:dismiss`` (Escape) primes a chord; a second tap
7
+ within the surface's window fires ``dismiss×2`` (the configurable
8
+ double-Escape action: tree / fork / clear). A first tap of
9
+ ``edit:clearLine`` (Ctrl+U on an already-empty buffer) primes a chord
10
+ whose second tap fires ``clear×2`` (request exit). Any other intent
11
+ breaks the latch. :func:`advance_chord` is pure; the app owns the latch
12
+ value and the :data:`CHORD_WINDOW_MS` timer that expires it (simply
13
+ resetting to :data:`NO_CHORD` — ``App.set_timer`` in Textual).
14
+
15
+ 2. **The Ctrl+C exit window** (TS ``TerminalConsole.tsx`` ``flow:interrupt``
16
+ case — extracted here as a pure helper with an injectable clock).
17
+ Ctrl+C is clear-then-exit: a busy turn is aborted first; otherwise a
18
+ press on a non-empty buffer clears it, and a press on an empty buffer
19
+ arms a window — a second empty-buffer press within
20
+ :data:`CTRL_C_EXIT_WINDOW_MS` asks the host to leave.
21
+ :func:`advance_exit_window` folds one press into the carried
22
+ :class:`ExitWindow`; the caller injects ``now_ms`` (the clock seam), so
23
+ the whole machine is deterministic under test.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass
29
+ from types import MappingProxyType
30
+ from typing import Literal, Mapping, TypeAlias
31
+
32
+ from .intents import ConsoleIntent
33
+
34
+ __all__ = [
35
+ "CHORD_VERBS",
36
+ "CHORD_WINDOW_MS",
37
+ "CTRL_C_EXIT_WINDOW_MS",
38
+ "ChordLatch",
39
+ "ChordOutcome",
40
+ "ChordStep",
41
+ "ExitAction",
42
+ "ExitStep",
43
+ "ExitWindow",
44
+ "NO_CHORD",
45
+ "NO_EXIT_WINDOW",
46
+ "advance_chord",
47
+ "advance_exit_window",
48
+ ]
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Double-tap latch (TS ``advanceChord`` — verbatim)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ #: How long a primed double-tap chord stays armed before the surface expires
56
+ #: it back to :data:`NO_CHORD` (TS ``CHORD_WINDOW_MS``).
57
+ CHORD_WINDOW_MS = 600
58
+
59
+ #: Which verbs participate in a double-tap chord, and what a repeat fires.
60
+ ChordOutcome: TypeAlias = Literal["dismiss×2", "clear×2"]
61
+
62
+ #: The verbs that can prime a chord, mapped to the outcome a repeat fires.
63
+ CHORD_VERBS: Mapping[str, ChordOutcome] = MappingProxyType(
64
+ {
65
+ "flow:dismiss": "dismiss×2",
66
+ "edit:clearLine": "clear×2",
67
+ }
68
+ )
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class ChordLatch:
73
+ """The cross-keystroke latch the surface carries between key events.
74
+
75
+ ``armed`` is the verb a prior keystroke primed (or ``None`` when no
76
+ chord is in flight). The surface resets it to ``None`` when its timeout
77
+ expires.
78
+ """
79
+
80
+ # The verb a previous keystroke armed, or ``None`` when nothing is primed.
81
+ armed: str | None = None
82
+
83
+
84
+ #: A fresh, unarmed latch.
85
+ NO_CHORD = ChordLatch()
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class ChordStep:
90
+ """The result of folding a fresh intent into the prior chord latch."""
91
+
92
+ # The latch to carry into the next keystroke.
93
+ next: ChordLatch
94
+ # The chord that fired on this keystroke, or ``None`` if none did.
95
+ fired: ChordOutcome | None
96
+
97
+
98
+ def advance_chord(latch: ChordLatch, intent: ConsoleIntent) -> ChordStep:
99
+ """Fold a fresh :class:`ConsoleIntent` into the prior :class:`ChordLatch`.
100
+
101
+ Pure: given the same latch and intent it always yields the same step. If
102
+ the incoming verb matches the armed verb and that verb is
103
+ chord-eligible, the chord fires and the latch resets. If the incoming
104
+ verb is chord-eligible but the latch was empty (or armed with a
105
+ different verb), it arms. Any other intent clears the latch. The timeout
106
+ that expires a stale latch is the surface's responsibility (it simply
107
+ resets to :data:`NO_CHORD`).
108
+
109
+ :param latch: the latch carried from the previous keystroke
110
+ :param intent: the freshly classified intent for this keystroke
111
+ """
112
+ outcome = CHORD_VERBS.get(intent.verb)
113
+ if outcome is None:
114
+ # Non-chord key: drop any primed chord.
115
+ return ChordStep(next=NO_CHORD, fired=None)
116
+ if latch.armed == intent.verb:
117
+ # Second matching tap — fire and disarm.
118
+ return ChordStep(next=NO_CHORD, fired=outcome)
119
+ # First tap of a chord-eligible key — arm it.
120
+ return ChordStep(next=ChordLatch(armed=intent.verb), fired=None)
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Ctrl+C exit window (TS ``flow:interrupt`` case, as a pure machine)
125
+ # ---------------------------------------------------------------------------
126
+
127
+ #: How long after an empty-buffer Ctrl+C a second press still exits
128
+ #: (TS ``CTRL_C_EXIT_WINDOW_MS``).
129
+ CTRL_C_EXIT_WINDOW_MS = 500
130
+
131
+ #: What one Ctrl+C press does, in TS branch order: abort a busy turn, clear
132
+ #: a non-empty buffer, arm the window on the first empty-buffer press, exit
133
+ #: on the second within the window.
134
+ ExitAction: TypeAlias = Literal["abort", "clear", "arm", "exit"]
135
+
136
+
137
+ @dataclass(frozen=True, slots=True)
138
+ class ExitWindow:
139
+ """The carried Ctrl+C latch: when (ms) the window was armed, or ``None``
140
+ when unarmed. The TS code kept a raw timestamp ref initialized to ``0``
141
+ as the unarmed sentinel; ``None`` expresses the same state without the
142
+ epoch hack."""
143
+
144
+ armed_at_ms: float | None = None
145
+
146
+
147
+ #: A fresh, unarmed exit window.
148
+ NO_EXIT_WINDOW = ExitWindow()
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class ExitStep:
153
+ """The result of folding one Ctrl+C press into the carried window."""
154
+
155
+ # The window to carry into the next press.
156
+ next: ExitWindow
157
+ # What the surface should do for this press.
158
+ action: ExitAction
159
+
160
+
161
+ def advance_exit_window(
162
+ window: ExitWindow,
163
+ *,
164
+ busy: bool,
165
+ buffer_empty: bool,
166
+ now_ms: float,
167
+ window_ms: float = CTRL_C_EXIT_WINDOW_MS,
168
+ ) -> ExitStep:
169
+ """Fold one Ctrl+C press into the prior :class:`ExitWindow`.
170
+
171
+ Pure, with the clock injected as ``now_ms`` (the caller supplies its
172
+ monotonic-ms reading; tests supply literals). Branch order is the TS
173
+ handler's exactly:
174
+
175
+ - a **busy** turn is aborted and the window disarms (the press never
176
+ doubles as an exit step);
177
+ - an **empty buffer**: a press within ``window_ms`` of the armed
178
+ timestamp fires ``exit`` and disarms; otherwise it (re-)arms at
179
+ ``now_ms``;
180
+ - a **non-empty buffer**: the press clears the composer and disarms —
181
+ the window is only ever armed by a press that left an empty buffer
182
+ untouched.
183
+
184
+ :param window: the window carried from the previous press
185
+ :param busy: whether a turn is currently in flight
186
+ :param buffer_empty: whether the composer buffer is empty
187
+ :param now_ms: the injected clock reading, in milliseconds
188
+ :param window_ms: the exit window width (defaults to
189
+ :data:`CTRL_C_EXIT_WINDOW_MS`)
190
+ """
191
+ if busy:
192
+ return ExitStep(next=NO_EXIT_WINDOW, action="abort")
193
+ if not buffer_empty:
194
+ return ExitStep(next=NO_EXIT_WINDOW, action="clear")
195
+ if window.armed_at_ms is not None and now_ms - window.armed_at_ms <= window_ms:
196
+ return ExitStep(next=NO_EXIT_WINDOW, action="exit")
197
+ return ExitStep(next=ExitWindow(armed_at_ms=now_ms), action="arm")
@@ -0,0 +1,113 @@
1
+ """Directory reader — the live ``os.scandir``-backed :data:`DirReader`.
2
+
3
+ Port of TS ``src/console/input/dir-reader.ts``. The completion engine
4
+ (:func:`induscode.console.input.providers.complete_at`) is pure: its path
5
+ branch lists a directory through an injected :data:`DirReader` rather than
6
+ touching the disk itself, so the suggestion logic stays unit-testable
7
+ against a synthetic tree. This module is the one place that binds that seam
8
+ to a real filesystem.
9
+
10
+ :func:`create_dir_reader` returns a reader that resolves a (possibly
11
+ relative) directory path against a base directory — the session working
12
+ directory by default — and lists its immediate entries as :class:`DirEntry`
13
+ records. Every failure mode (a missing directory, a permission error, a
14
+ path that names a file) collapses to an empty list, so the composer never
15
+ raises while the user is mid-type. A symlink is classified by whether it
16
+ ultimately resolves to a directory, falling back to non-directory when the
17
+ target cannot be stat-ed (the TS ``statSync`` fallback, exactly).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import stat
24
+ from collections.abc import Callable, Sequence
25
+ from dataclasses import dataclass
26
+ from typing import TypeAlias
27
+
28
+ __all__ = [
29
+ "DirEntry",
30
+ "DirReader",
31
+ "create_dir_reader",
32
+ ]
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class DirEntry:
37
+ """One pure directory-listing row: a leaf name, flagged dir-or-file."""
38
+
39
+ # The leaf name (no path separators).
40
+ name: str
41
+ # Whether the entry is a directory (symlinks resolved; see module doc).
42
+ is_dir: bool
43
+
44
+
45
+ #: An injected directory listing so the path-completion branch stays pure
46
+ #: and testable. Given a directory path (relative to the working dir), it
47
+ #: returns that directory's immediate entries, or an empty list when the
48
+ #: path does not exist or cannot be read. The live console binds this to
49
+ #: :func:`create_dir_reader`; tests bind it to a synthetic tree.
50
+ DirReader: TypeAlias = Callable[[str], Sequence[DirEntry]]
51
+
52
+
53
+ def _entry_is_dir(base: str, name: str, is_dirent: bool, is_symlink: bool) -> bool:
54
+ """Decide whether a listed entry should be treated as a directory.
55
+
56
+ A plain directory dirent answers immediately. A symlink is followed with
57
+ a ``stat`` so a link to a directory completes with a trailing slash; if
58
+ the link target cannot be resolved (dangling, permission), the entry is
59
+ reported as a non-directory rather than raising.
60
+ """
61
+ if is_dirent:
62
+ return True
63
+ if not is_symlink:
64
+ return False
65
+ try:
66
+ return stat.S_ISDIR(os.stat(os.path.join(base, name)).st_mode)
67
+ except OSError:
68
+ return False
69
+
70
+
71
+ def _resolve(base: str, dir_path: str) -> str:
72
+ """``path.resolve(cwd, dirPath)`` parity: an absolute request is used
73
+ as-is, a relative one is joined onto the base directory."""
74
+ if os.path.isabs(dir_path):
75
+ return os.path.normpath(dir_path)
76
+ return os.path.normpath(os.path.join(base, dir_path))
77
+
78
+
79
+ def create_dir_reader(cwd: str | None = None) -> DirReader:
80
+ """Build a live :data:`DirReader` rooted at ``cwd``.
81
+
82
+ The returned reader resolves each requested directory path against
83
+ ``cwd`` (an absolute request is used as-is), lists it, and maps each
84
+ child to a :class:`DirEntry`. Any error returns ``[]``.
85
+
86
+ :param cwd: the base directory relative requests resolve against
87
+ (defaults to the process working directory, captured here)
88
+ """
89
+ base = cwd if cwd is not None else os.getcwd()
90
+
91
+ def read(dir_path: str) -> Sequence[DirEntry]:
92
+ target = _resolve(base, dir_path)
93
+ try:
94
+ with os.scandir(target) as scan:
95
+ dirents = list(scan)
96
+ except OSError:
97
+ return []
98
+ entries: list[DirEntry] = []
99
+ for dirent in dirents:
100
+ try:
101
+ is_dirent = dirent.is_dir(follow_symlinks=False)
102
+ is_symlink = dirent.is_symlink()
103
+ except OSError:
104
+ is_dirent, is_symlink = False, False
105
+ entries.append(
106
+ DirEntry(
107
+ name=dirent.name,
108
+ is_dir=_entry_is_dir(target, dirent.name, is_dirent, is_symlink),
109
+ )
110
+ )
111
+ return entries
112
+
113
+ return read
@@ -0,0 +1,258 @@
1
+ """Console intents — the keystroke-verb vocabulary and the chord→intent table.
2
+
3
+ Port of the *vocabulary* half of TS ``src/console/input/keymap.ts``. The TS
4
+ console classified raw Ink ``(input, key)`` events through a pure ``readKey``
5
+ into :class:`ConsoleIntent` verbs; in the Python console most of that
6
+ classifier **collapses into the framework**:
7
+
8
+ - Editor-level verbs (typing, deletion, caret motion, history, submit,
9
+ completion accept) are delegated to
10
+ :class:`indusagi.tui.keybindings.EditorKeybindingsManager` +
11
+ :class:`indusagi.tui.editor.EditorCore`, driven by the framework
12
+ ``PromptEditor`` widget. :data:`EDITOR_DELEGATED` documents the exact
13
+ verb → ``EditorAction`` mapping, as data, so the delegation is pinned by
14
+ tests rather than prose.
15
+ - App-level verbs survive as :data:`INTENT_TABLE` — a chord → intent map in
16
+ the framework's ``KeyId`` vocabulary (``indusagi.tui.keys``). The wave-3
17
+ ``ConsoleApp`` derives its Textual ``BINDINGS``/``action_*`` methods from
18
+ this table; the table itself stays pure data so the chord→verb matrix is
19
+ unit-tested without mounting Textual.
20
+
21
+ The verb strings are the TS union **verbatim** (they are user-visible in
22
+ ``/keys`` and stay byte-identical); the chord spellings move from Ink's flag
23
+ bag to ``KeyId`` strings (``ctrl+r``, ``shift+tab``, ``alt+up``, ...), which
24
+ is the re-pinning analysis 02 risk-5 calls for.
25
+
26
+ Ink-specific quirks the framework dissolves (deliberately NOT ported):
27
+
28
+ - the macOS Backspace-as-``key.delete`` fold and the raw DEL/BS control
29
+ bytes — ``indusagi.tui.keys`` already maps ``\\x7f``/``\\x08`` to
30
+ ``backspace``, so ``edit:erasePrev`` needs no quirk table;
31
+ - the printable-vs-control-byte filter (``isPrintable``) — Textual's
32
+ ``events.Key.is_printable`` plays that role inside ``PromptEditor``;
33
+ - bracketed-paste delimiter stripping and the paste burst — Textual delivers
34
+ one assembled ``events.Paste`` (see ``tests/console/test_input.py`` §3).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ from dataclasses import dataclass
40
+ from types import MappingProxyType
41
+ from typing import Literal, Mapping, TypeAlias
42
+
43
+ __all__ = [
44
+ "CONSOLE_VERBS",
45
+ "ConsoleIntent",
46
+ "ConsoleVerb",
47
+ "EDITOR_DELEGATED",
48
+ "INTENT_TABLE",
49
+ "NO_INTENT",
50
+ ]
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Console verb vocabulary (TS ``ConsoleVerb`` — verbatim)
55
+ # ---------------------------------------------------------------------------
56
+
57
+ #: The console's own keystroke-intent verbs. Deliberately *not* a mirror of
58
+ #: any upstream keybinding enum: each verb names what the console does,
59
+ #: grouped by concern (editing / caret / session control / model / queue /
60
+ #: input sources / overlays / view toggles / inert). Strings are the TS
61
+ #: union verbatim.
62
+ ConsoleVerb: TypeAlias = Literal[
63
+ # Editing
64
+ "text:type",
65
+ "text:newline",
66
+ "edit:erasePrev",
67
+ "edit:eraseNext",
68
+ "edit:clearLine",
69
+ # Caret / navigation
70
+ "nav:left",
71
+ "nav:right",
72
+ "nav:home",
73
+ "nav:end",
74
+ "nav:up",
75
+ "nav:down",
76
+ # Session control
77
+ "flow:submit",
78
+ "flow:dismiss",
79
+ "flow:accept",
80
+ "flow:interrupt",
81
+ "flow:suspend",
82
+ "flow:cycleModel",
83
+ # Model / reasoning
84
+ "model:cycleThinking",
85
+ # Queue
86
+ "queue:dequeue",
87
+ # Input sources
88
+ "input:pasteImage",
89
+ "input:externalEditor",
90
+ # Overlays
91
+ "overlay:open",
92
+ # View toggles
93
+ "view:expandTools",
94
+ "view:toggleReasoning",
95
+ # Inert
96
+ "none",
97
+ ]
98
+
99
+ #: Runtime mirror of the :data:`ConsoleVerb` union, in declaration order —
100
+ #: the coverage anchor for the union test (exhaustiveness via tests, not
101
+ #: types; cross-cutting rule 1).
102
+ CONSOLE_VERBS: tuple[str, ...] = (
103
+ "text:type",
104
+ "text:newline",
105
+ "edit:erasePrev",
106
+ "edit:eraseNext",
107
+ "edit:clearLine",
108
+ "nav:left",
109
+ "nav:right",
110
+ "nav:home",
111
+ "nav:end",
112
+ "nav:up",
113
+ "nav:down",
114
+ "flow:submit",
115
+ "flow:dismiss",
116
+ "flow:accept",
117
+ "flow:interrupt",
118
+ "flow:suspend",
119
+ "flow:cycleModel",
120
+ "model:cycleThinking",
121
+ "queue:dequeue",
122
+ "input:pasteImage",
123
+ "input:externalEditor",
124
+ "overlay:open",
125
+ "view:expandTools",
126
+ "view:toggleReasoning",
127
+ "none",
128
+ )
129
+
130
+
131
+ @dataclass(frozen=True, slots=True)
132
+ class ConsoleIntent:
133
+ """A classified keystroke: a :data:`ConsoleVerb` plus the small payload a
134
+ verb may carry — the literal text for a ``text:type`` insert, or the
135
+ target overlay for an ``overlay:open``. Every other verb is
136
+ self-contained, so both payload fields default to ``None`` and are
137
+ present only on their owning verb.
138
+ """
139
+
140
+ # What the keystroke means to the console.
141
+ verb: ConsoleVerb
142
+ # The literal text to splice, present only for ``text:type``.
143
+ text: str | None = None
144
+ # The overlay to raise (a console ``ModalKind`` literal; the full union
145
+ # lands with the wave-2 console contract), present only for
146
+ # ``overlay:open``.
147
+ overlay: str | None = None
148
+
149
+
150
+ #: The inert intent — a keystroke the console ignores.
151
+ NO_INTENT = ConsoleIntent(verb="none")
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # The intent table (app-level chords → intents, as data)
156
+ # ---------------------------------------------------------------------------
157
+
158
+ #: Key chord → :class:`ConsoleIntent`, in the framework ``KeyId`` spelling.
159
+ #:
160
+ #: This is the surviving app-level slice of the TS ``readKey`` matrix; the
161
+ #: wave-3 ``ConsoleApp`` derives its Textual ``BINDINGS`` from these rows.
162
+ #: Editor-level chords are absent by design — they are resolved inside the
163
+ #: framework ``PromptEditor`` (see :data:`EDITOR_DELEGATED`) and never reach
164
+ #: the app while the composer has focus.
165
+ #:
166
+ #: Two rows are deliberately dual-role:
167
+ #:
168
+ #: - ``escape`` → ``flow:dismiss``: while the autocomplete popup is open the
169
+ #: editor consumes Escape as ``selectCancel``; otherwise it bubbles to the
170
+ #: app, which dismisses an overlay / aborts a busy turn / feeds the
171
+ #: double-Esc chord latch (:func:`induscode.console.input.chord.advance_chord`).
172
+ #: - ``ctrl+u`` → ``edit:clearLine``: the *editing* effect is the framework's
173
+ #: ``deleteToLineStart`` (readline unix-line-discard, editor-consumed);
174
+ #: the app additionally observes the chord on an already-empty buffer to
175
+ #: arm the ``clear×2`` exit latch, exactly like the TS console.
176
+ INTENT_TABLE: Mapping[str, ConsoleIntent] = MappingProxyType(
177
+ {
178
+ # Session control.
179
+ "ctrl+c": ConsoleIntent(verb="flow:interrupt"),
180
+ "ctrl+z": ConsoleIntent(verb="flow:suspend"),
181
+ # Rotate the active model forward in scope (two chords, one verb —
182
+ # the TS table maps both ctrl+n and ctrl+p to the same rotation).
183
+ "ctrl+n": ConsoleIntent(verb="flow:cycleModel"),
184
+ "ctrl+p": ConsoleIntent(verb="flow:cycleModel"),
185
+ # Reasoning ladder + view toggles.
186
+ "shift+tab": ConsoleIntent(verb="model:cycleThinking"),
187
+ "ctrl+t": ConsoleIntent(verb="view:toggleReasoning"),
188
+ # The per-scope model picker is reached deliberately via
189
+ # /scoped-models, so this chord is free to toggle full tool-output
190
+ # rendering instead.
191
+ "ctrl+o": ConsoleIntent(verb="view:expandTools"),
192
+ # Input sources.
193
+ "ctrl+v": ConsoleIntent(verb="input:pasteImage"),
194
+ # The settings overlay is reached via /settings; this chord hands the
195
+ # composer buffer off to the user's external editor instead.
196
+ "ctrl+g": ConsoleIntent(verb="input:externalEditor"),
197
+ # Overlays raised directly from a control chord, without typing a
198
+ # slash command.
199
+ "ctrl+r": ConsoleIntent(verb="overlay:open", overlay="sessions"),
200
+ "ctrl+l": ConsoleIntent(verb="overlay:open", overlay="models"),
201
+ # Queue: pull the newest queued input back into the composer.
202
+ "alt+up": ConsoleIntent(verb="queue:dequeue"),
203
+ # Dual-role rows (see the table doc above).
204
+ "escape": ConsoleIntent(verb="flow:dismiss"),
205
+ "ctrl+u": ConsoleIntent(verb="edit:clearLine"),
206
+ }
207
+ )
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # Editor delegation (which TS verbs collapsed into the framework)
212
+ # ---------------------------------------------------------------------------
213
+
214
+ #: TS ``readKey`` verb → the framework ``EditorAction`` name(s) that absorb
215
+ #: it (resolved by ``EditorKeybindingsManager``; dispatched by
216
+ #: ``PromptEditor`` / ``EditorCore``). Expressed as data so the coverage
217
+ #: test pins that every verb in :data:`CONSOLE_VERBS` is either an app
218
+ #: intent, editor-delegated, or the inert ``none``.
219
+ #:
220
+ #: Notes on the non-obvious rows:
221
+ #:
222
+ #: - ``text:type`` has no named action — it is ``PromptEditor``'s printable-
223
+ #: character path (``EditorCore.insert_character``), which also absorbs the
224
+ #: TS ``isPrintable`` control-byte filter.
225
+ #: - ``text:newline``: the bound chord is ``shift+enter``; the widget also
226
+ #: keeps the TS fallbacks (raw ``ctrl+j`` / ``alt+enter`` / a trailing
227
+ #: ``\\`` before Enter) for terminals without an extended protocol.
228
+ #: - ``edit:erasePrev``: the framework key decoder folds ``\\x7f``/``\\x08``
229
+ #: into ``backspace``, so the TS macOS quirk table dies here.
230
+ #: - ``edit:eraseNext`` had no chord in the TS matrix (the verb existed in
231
+ #: the vocabulary only); the framework gives it ``delete`` natively.
232
+ #: - ``nav:home``/``nav:end``: the TS console collapsed PageUp/PageDown onto
233
+ #: buffer start/end; the framework editor gives PageUp/PageDown true
234
+ #: visual-page motion (``pageUp``/``pageDown`` actions) — a deliberate,
235
+ #: documented divergence (the richer behavior supersedes the collapse).
236
+ #: - ``nav:up``/``nav:down`` are history-aware in ``EditorCore`` and double
237
+ #: as ``selectUp``/``selectDown`` while the completion popup is open.
238
+ #: - ``flow:submit`` doubles as ``selectConfirm`` in the popup; ``flow:accept``
239
+ #: is the popup/Tab completion accept (``tab``); ``flow:dismiss`` is
240
+ #: editor-consumed only while the popup is open (``selectCancel``).
241
+ EDITOR_DELEGATED: Mapping[str, tuple[str, ...]] = MappingProxyType(
242
+ {
243
+ "text:type": (),
244
+ "text:newline": ("newLine",),
245
+ "edit:erasePrev": ("deleteCharBackward",),
246
+ "edit:eraseNext": ("deleteCharForward",),
247
+ "edit:clearLine": ("deleteToLineStart",),
248
+ "nav:left": ("cursorLeft",),
249
+ "nav:right": ("cursorRight",),
250
+ "nav:home": ("cursorLineStart", "pageUp"),
251
+ "nav:end": ("cursorLineEnd", "pageDown"),
252
+ "nav:up": ("cursorUp", "selectUp"),
253
+ "nav:down": ("cursorDown", "selectDown"),
254
+ "flow:submit": ("submit", "selectConfirm"),
255
+ "flow:accept": ("tab",),
256
+ "flow:dismiss": ("selectCancel",),
257
+ }
258
+ )