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,232 @@
1
+ """Session overlays — the transcript navigation dialogs.
2
+
3
+ Port of TS ``src/console/overlays/sessions.tsx``. This module owns the three
4
+ modal kinds that browse persisted or in-flight transcript state: the session
5
+ resume/list picker (``sessions``), the transcript-tree navigator (``tree``),
6
+ and the prior-user-turn picker (``userTurns``) used when branching or
7
+ forking.
8
+
9
+ Each flow maps the app's session vocabulary onto the framework dialog
10
+ shapes — a :class:`~induscode.sessions.SavedSession` becomes a
11
+ :class:`~indusagi.react_ink.SessionInfo`, a
12
+ :class:`~induscode.sessions.BranchNode` becomes a ``SessionTreeOption``, and
13
+ a :class:`~induscode.sessions.PriorTurn` becomes a ``UserMessageOption``.
14
+
15
+ Dialog-API inversion (port plan analysis 02, risk 1): the TS bodies loaded
16
+ their catalog/tree/turn data in ``useEffect`` and confirmed through callback
17
+ props; here each flow awaits its data load *before* pushing the dialog,
18
+ awaits the ``ModalScreen``'s dismissal result, and drives the matching
19
+ conductor verb (``resume`` / ``navigate_tree`` / ``fork``) after the await.
20
+ The session picker's mutating callbacks (deep re-list, delete, rename)
21
+ remain callbacks — the framework :class:`~indusagi.react_ink.SessionDialog`
22
+ invokes them mid-flight and maintains its own local list copy.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from datetime import datetime
28
+ from typing import TYPE_CHECKING, Any, Sequence
29
+
30
+ from indusagi.react_ink import (
31
+ SessionDialog,
32
+ SessionInfo,
33
+ SessionTreeOption,
34
+ TreeDialog,
35
+ UserMessageDialog,
36
+ UserMessageOption,
37
+ )
38
+
39
+ from induscode.console.contract import ConsoleEvent, OverlayServices
40
+
41
+ if TYPE_CHECKING:
42
+ from textual.app import App
43
+
44
+ from induscode.sessions import BranchNode, PriorTurn, SavedSession
45
+
46
+ __all__ = [
47
+ "run_prior_turns",
48
+ "run_session_picker",
49
+ "run_tree_navigator",
50
+ "to_session_info",
51
+ "to_tree_option",
52
+ "to_turn_option",
53
+ ]
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # App → framework shape mappers
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def to_session_info(row: "SavedSession") -> SessionInfo:
62
+ """Project a catalog row onto the framework session-info shape a picker
63
+ lists (the TS ``toSessionInfo``; an absent ``lastModified`` reads as 0)."""
64
+ last_modified = row.lastModified if row.lastModified is not None else 0
65
+ return SessionInfo(
66
+ id=row.id,
67
+ path=row.path,
68
+ name=row.name,
69
+ modified=datetime.fromtimestamp(last_modified / 1000.0),
70
+ lastModified=int(last_modified),
71
+ size=row.size,
72
+ messageCount=row.messageCount,
73
+ firstMessage=row.preview,
74
+ )
75
+
76
+
77
+ def to_tree_option(node: "BranchNode") -> SessionTreeOption:
78
+ """Project a navigator row onto the framework tree-option shape."""
79
+ return SessionTreeOption(
80
+ id=node.id,
81
+ label=node.label,
82
+ depth=node.depth,
83
+ isLeaf=node.isLeaf,
84
+ isCurrent=node.isCurrent,
85
+ )
86
+
87
+
88
+ def to_turn_option(turn: "PriorTurn") -> UserMessageOption:
89
+ """Project a fork candidate onto the framework user-message shape."""
90
+ return UserMessageOption(entryId=turn.entryId, text=turn.text, preview=turn.preview)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # sessions — the resume / list picker
95
+ # ---------------------------------------------------------------------------
96
+
97
+
98
+ async def run_session_picker(
99
+ app: "App[Any]",
100
+ payload: object | None,
101
+ services: OverlayServices | None,
102
+ ) -> tuple[ConsoleEvent, ...]:
103
+ """The session resume/list picker flow.
104
+
105
+ The shallow current-folder listing seeds the dialog; the all-scope toggle
106
+ loads the deep listing on demand. Picking a row resumes that session on
107
+ the conductor (faults stay silent — the conductor emits a fault signal);
108
+ delete/rename go through the :class:`~induscode.sessions.SessionLibrary`.
109
+ """
110
+ if services is None:
111
+ return ()
112
+
113
+ try:
114
+ rows = await services.sessions.list()
115
+ infos = [to_session_info(row) for row in rows]
116
+ except Exception:
117
+ infos = []
118
+
119
+ try:
120
+ current_path: str | None = services.sessions.path_of(
121
+ services.conductor.snapshot().head.sessionId
122
+ )
123
+ except Exception:
124
+ current_path = None
125
+
126
+ async def load_all(progress: object | None = None) -> Sequence[SessionInfo]:
127
+ try:
128
+ deep = await services.sessions.list(deep=True)
129
+ return [to_session_info(row) for row in deep]
130
+ except Exception:
131
+ return []
132
+
133
+ async def delete(session: SessionInfo) -> None:
134
+ try:
135
+ await services.sessions.remove(session.id)
136
+ except Exception:
137
+ # Ignore — a missing file is already gone.
138
+ pass
139
+
140
+ async def rename(session: SessionInfo, name: str) -> None:
141
+ try:
142
+ await services.sessions.rename(session.id, name)
143
+ except Exception:
144
+ # Ignore — collision / missing source surfaces via the unchanged list.
145
+ pass
146
+
147
+ chosen = await app.push_screen_wait(
148
+ SessionDialog(
149
+ current_sessions=infos,
150
+ current_session_path=current_path,
151
+ on_load_all_sessions=load_all,
152
+ on_delete_session=delete,
153
+ on_rename_session=rename,
154
+ )
155
+ )
156
+ if chosen is not None:
157
+ try:
158
+ await services.conductor.resume(chosen.id)
159
+ except Exception:
160
+ # Surface nothing here; the conductor emits a fault signal.
161
+ pass
162
+ return ()
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # tree — the branch navigator
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ async def run_tree_navigator(
171
+ app: "App[Any]",
172
+ payload: object | None,
173
+ services: OverlayServices | None,
174
+ ) -> tuple[ConsoleEvent, ...]:
175
+ """The transcript-tree navigator flow for the active session: open the
176
+ active transcript, flatten it to depth-annotated tree options, and jump
177
+ the conductor's head to whichever node the user picks."""
178
+ if services is None:
179
+ return ()
180
+
181
+ items: list[SessionTreeOption] = []
182
+ try:
183
+ session_id = services.conductor.snapshot().head.sessionId
184
+ store = await services.sessions.open(session_id)
185
+ if store is not None:
186
+ items = [to_tree_option(node) for node in services.sessions.tree(store)]
187
+ except Exception:
188
+ items = []
189
+
190
+ node = await app.push_screen_wait(TreeDialog(items=items))
191
+ if node is not None and node.id is not None:
192
+ try:
193
+ await services.conductor.navigate_tree(node.id)
194
+ except Exception:
195
+ # Surface nothing here; the conductor emits a fault signal.
196
+ pass
197
+ return ()
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # userTurns — the prior-turn fork picker
202
+ # ---------------------------------------------------------------------------
203
+
204
+
205
+ async def run_prior_turns(
206
+ app: "App[Any]",
207
+ payload: object | None,
208
+ services: OverlayServices | None,
209
+ ) -> tuple[ConsoleEvent, ...]:
210
+ """The prior-user-turn picker flow for the active session: list the past
211
+ user turns as fork candidates and fork the transcript at whichever entry
212
+ the user picks."""
213
+ if services is None:
214
+ return ()
215
+
216
+ items: list[UserMessageOption] = []
217
+ try:
218
+ session_id = services.conductor.snapshot().head.sessionId
219
+ store = await services.sessions.open(session_id)
220
+ if store is not None:
221
+ items = [to_turn_option(turn) for turn in services.sessions.prior_turns(store)]
222
+ except Exception:
223
+ items = []
224
+
225
+ turn = await app.push_screen_wait(UserMessageDialog(items=items))
226
+ if turn is not None:
227
+ try:
228
+ await services.conductor.fork(turn.entryId)
229
+ except Exception:
230
+ # Surface nothing here; the conductor emits a fault signal.
231
+ pass
232
+ return ()
@@ -0,0 +1,145 @@
1
+ """Console reducer — the single pure fold over :class:`ConsoleState`.
2
+
3
+ Port of TS ``src/console/reducer.ts``. The terminal surface's UI-local state
4
+ is reduced from one immutable :class:`~induscode.console.contract.ConsoleState`
5
+ value, mutated only by dispatching a
6
+ :data:`~induscode.console.contract.ConsoleEvent` into
7
+ :func:`console_reducer`. This module is the one place those transitions live:
8
+ it is a pure function (no Textual, no I/O, no timers) that, given a prior
9
+ state and an event, returns a *fresh* state — it never mutates its input.
10
+ That makes every transition unit-testable in isolation and keeps the surface
11
+ a thin shell that only wires events in and renders the result out.
12
+
13
+ The transitions fall into two families, mirroring the event grouping:
14
+
15
+ - **Transcript view** (``rows:*``, ``block:append``, ``blocks:clear``)
16
+ appends and patches the rendered rows projected from the conductor's
17
+ ``SessionSignal`` stream (the surface translates each signal into a row
18
+ event).
19
+ - **Overlays / status / theme / toggles / busy** flip the small UI-local
20
+ flags the surface reads to decide what chrome to render.
21
+
22
+ Port delta (locked; analysis 02 §7): the TS composer-edit and history-recall
23
+ families (``buffer:*``, ``caret:*``, ``history:*`` — ~40% of the TS file) are
24
+ gone. The framework ``EditorCore`` owns the buffer, caret, and history ring,
25
+ so the console reducer keeps only the rows/blocks/modal/status/scheme/
26
+ toggles/busy slices.
27
+
28
+ Live session data (messages, usage, model) is *not* stored here — it is read
29
+ straight from the conductor snapshot — so this state stays purely UI-local
30
+ and the reducer never needs to know about the agent loop.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from dataclasses import replace
36
+ from typing import Any
37
+
38
+ from .contract import (
39
+ EMPTY_CONSOLE_STATE,
40
+ NO_MODAL,
41
+ ConsoleEvent,
42
+ ConsoleState,
43
+ transition_modal,
44
+ )
45
+
46
+ __all__ = [
47
+ "console_reducer",
48
+ "init_console_state",
49
+ ]
50
+
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Transcript-view transitions
54
+ # ---------------------------------------------------------------------------
55
+
56
+
57
+ def _patch_row(state: ConsoleState, row_id: str, text: str) -> ConsoleState:
58
+ """Replace the text of the row matching ``row_id``.
59
+
60
+ Used to grow a streaming ``answer``/``reason`` row in place: the row
61
+ keeps its id (so the renderer reconciles) while its text is swapped. A
62
+ missing id is a no-op rather than an error — the *same* state object is
63
+ returned — so a late patch for a pruned row is harmless.
64
+ """
65
+ found = False
66
+ rows = []
67
+ for row in state.rows:
68
+ if row.id != row_id:
69
+ rows.append(row)
70
+ continue
71
+ found = True
72
+ rows.append(replace(row, text=text))
73
+ return replace(state, rows=tuple(rows)) if found else state
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # The reducer
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ def console_reducer(state: ConsoleState, event: ConsoleEvent) -> ConsoleState:
82
+ """Fold a :data:`ConsoleEvent` into a :class:`ConsoleState`, returning a
83
+ fresh state.
84
+
85
+ Pure and total: every event has a defined transition, and the input state
86
+ is never mutated. The TS ``switch`` was compiler-exhaustive over the
87
+ event union; the Python port fails loud on an unknown tag and a
88
+ key-coverage test pins every member of ``CONSOLE_EVENT_TYPES`` to a
89
+ transition (cross-cutting rule 1).
90
+
91
+ :param state: the prior console state (left untouched)
92
+ :param event: the action to apply
93
+ :raises ValueError: on an event outside the :data:`ConsoleEvent` union
94
+ """
95
+ tag = event.type
96
+
97
+ # Transcript view --------------------------------------------------------
98
+ if tag == "rows:set":
99
+ return replace(state, rows=tuple(event.rows))
100
+ if tag == "rows:append":
101
+ return replace(state, rows=(*state.rows, event.row))
102
+ if tag == "rows:patch":
103
+ return _patch_row(state, event.id, event.text)
104
+ if tag == "block:append":
105
+ return replace(state, blocks=(*state.blocks, event.block))
106
+ if tag == "blocks:clear":
107
+ return state if len(state.blocks) == 0 else replace(state, blocks=())
108
+
109
+ # Overlays, status, theme, toggles, busy ---------------------------------
110
+ if tag == "modal:open":
111
+ return replace(state, modal=transition_modal(event.kind, event.payload))
112
+ if tag == "modal:close":
113
+ return replace(state, modal=NO_MODAL)
114
+ if tag == "status:set":
115
+ return replace(state, status=event.status)
116
+ if tag == "status:clear":
117
+ return state if state.status is None else replace(state, status=None)
118
+ if tag == "scheme:set":
119
+ return replace(state, scheme=event.scheme)
120
+ if tag == "toggle:reasoning":
121
+ return replace(state, show_reasoning=not state.show_reasoning)
122
+ if tag == "toggle:images":
123
+ return replace(state, show_images=not state.show_images)
124
+ if tag == "busy:set":
125
+ return replace(state, busy=event.busy)
126
+ if tag == "tick":
127
+ return replace(state, tick=state.tick + 1)
128
+
129
+ raise ValueError(f"unknown console event: {tag!r}")
130
+
131
+
132
+ def init_console_state(**overrides: Any) -> ConsoleState:
133
+ """Build the initial :class:`ConsoleState` a console mounts with.
134
+
135
+ Starts from the frozen :data:`EMPTY_CONSOLE_STATE` seed and overlays any
136
+ caller-supplied UI preferences (scheme, the display toggles). Pure and
137
+ deterministic — no runtime is consulted — so the first render is stable.
138
+ With no overrides the shared empty constant itself is returned, matching
139
+ the TS identity behaviour.
140
+
141
+ :param overrides: optional UI-local preference fields to seed
142
+ """
143
+ if not overrides:
144
+ return EMPTY_CONSOLE_STATE
145
+ return replace(EMPTY_CONSOLE_STATE, **overrides)
@@ -0,0 +1,156 @@
1
+ """Startup resume picker — the ``--resume`` Textual mount.
2
+
3
+ Port of the picker half of TS ``src/launch/pickers.ts``
4
+ ``defaultResumeDeps().mountPicker``. The TS flow rendered the framework
5
+ React-Ink ``StartupSessionPicker`` on a real TTY *before* the main console
6
+ mounted and resolved with the session the user chose. This module is the
7
+ Python counterpart: it runs a tiny, dedicated Textual app that hosts the
8
+ framework :class:`~indusagi.react_ink.StartupSessionPicker` modal, awaits the
9
+ user's selection, and resolves it back to the launch layer's
10
+ :class:`~induscode.launch.contract.ResumeRef`.
11
+
12
+ Why a dedicated app rather than the in-console ``/resume`` overlay: the
13
+ ``--resume`` flag is honoured by the boot ``repl_runner`` *before* the
14
+ :class:`~induscode.console.app.ConsoleApp` exists (the restored transcript
15
+ must be in place from the console's first frame), so there is no running
16
+ Textual app to ``push_screen`` onto yet. This host app exists only to show
17
+ the one picker and exit with the choice — the same UX the TS Ink mount gave:
18
+ the picker is shown, the user selects, and that session is the one the runner
19
+ resumes.
20
+
21
+ Shape bridge: the launch layer's ``ResumeRef`` is an
22
+ :class:`indusagi.agent.SessionInfo`; the framework picker consumes the
23
+ distinct (but field-compatible) :class:`indusagi.react_ink.SessionInfo`. The
24
+ mount converts each row for display and keeps a path-keyed index so the chosen
25
+ react-ink row is resolved back to the originating ``ResumeRef`` — exactly the
26
+ ``byPath`` round-trip the TS ``mountPicker`` performed.
27
+
28
+ This module performs a real Textual mount, so the boot layer imports it
29
+ lazily (through the injected :class:`~induscode.launch.pickers.ResumeDeps`)
30
+ and never pays the Textual import cost on a headless / non-interactive path.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import sys
36
+ from collections.abc import Sequence
37
+ from dataclasses import dataclass
38
+
39
+ from textual.app import App, ComposeResult
40
+ from textual.widgets import Static
41
+
42
+ from indusagi.react_ink import SessionInfo as PickerSessionInfo
43
+ from indusagi.react_ink import StartupSessionPicker
44
+
45
+ from induscode.launch.contract import ResumeRef
46
+ from induscode.launch.pickers import ResumeDeps
47
+
48
+ __all__ = [
49
+ "StartupResumeDeps",
50
+ "default_startup_resume_deps",
51
+ "to_picker_session_info",
52
+ ]
53
+
54
+
55
+ def to_picker_session_info(ref: ResumeRef) -> PickerSessionInfo:
56
+ """Project a launch-layer :class:`ResumeRef`
57
+ (:class:`indusagi.agent.SessionInfo`) onto the framework picker's
58
+ :class:`indusagi.react_ink.SessionInfo`. The two carry the same fields;
59
+ this copies them across so the picker renders the row exactly as the
60
+ in-console session dialog does."""
61
+ return PickerSessionInfo(
62
+ id=ref.id,
63
+ path=ref.path,
64
+ modified=ref.modified,
65
+ lastModified=ref.lastModified,
66
+ name=ref.name,
67
+ size=ref.size,
68
+ cwd=ref.cwd,
69
+ messageCount=ref.messageCount,
70
+ firstMessage=ref.firstMessage,
71
+ allMessagesText=ref.allMessagesText,
72
+ )
73
+
74
+
75
+ class _StartupPickerApp(App[None]):
76
+ """A throwaway Textual app whose only job is to push the resume picker as
77
+ a modal screen, capture the chosen session, and exit. Nothing is rendered
78
+ on the base screen — the picker fills it."""
79
+
80
+ CSS = """
81
+ Screen {
82
+ align: center middle;
83
+ }
84
+ """
85
+
86
+ def __init__(
87
+ self, sessions: Sequence[PickerSessionInfo], total_count: int
88
+ ) -> None:
89
+ super().__init__()
90
+ self._sessions = list(sessions)
91
+ self._total_count = total_count
92
+ #: The path of the row the user chose, or ``None`` on dismissal.
93
+ self.chosen_path: str | None = None
94
+
95
+ def compose(self) -> ComposeResult:
96
+ # An empty base; the picker modal is pushed on mount.
97
+ yield Static("")
98
+
99
+ def on_mount(self) -> None:
100
+ self.push_screen(
101
+ StartupSessionPicker(
102
+ sessions=self._sessions, total_count=self._total_count
103
+ ),
104
+ self._on_picked,
105
+ )
106
+
107
+ def _on_picked(self, chosen: PickerSessionInfo | None) -> None:
108
+ """The picker dismissed — stash the choice and tear the host app
109
+ down so :meth:`run_async` returns control to the runner."""
110
+ self.chosen_path = None if chosen is None else chosen.path
111
+ self.exit(None)
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class StartupResumeDeps:
116
+ """A live :class:`~induscode.launch.pickers.ResumeDeps` that mounts the
117
+ real Textual picker.
118
+
119
+ The TTY probe is the same one the launch default used (stdin *and* stdout
120
+ must be terminals); the picker mount runs the dedicated
121
+ :class:`_StartupPickerApp` and resolves the chosen
122
+ :class:`ResumeRef`. This replaces the launch layer's raising default on
123
+ the interactive ``--resume`` path while leaving the non-TTY
124
+ newest-session fast path — which never reaches ``mount_picker`` — exactly
125
+ as it was.
126
+ """
127
+
128
+ def is_interactive(self) -> bool:
129
+ return bool(sys.stdin.isatty() and sys.stdout.isatty())
130
+
131
+ async def mount_picker(
132
+ self, sessions: Sequence[ResumeRef], total_count: int
133
+ ) -> ResumeRef | None:
134
+ """Mount the framework picker over ``sessions`` and resolve with the
135
+ chosen row, or ``None`` when the user dismisses it.
136
+
137
+ Mirrors the TS ``mountPicker``: the picker is handed framework
138
+ session-info records and reports the one the user selected; the chosen
139
+ path is resolved back to the originating ``ResumeRef`` so the runner
140
+ maps it to the session id it resumes by."""
141
+ by_path = {ref.path: ref for ref in sessions}
142
+ picker_rows = [to_picker_session_info(ref) for ref in sessions]
143
+
144
+ app = _StartupPickerApp(picker_rows, total_count)
145
+ await app.run_async()
146
+
147
+ if app.chosen_path is None:
148
+ return None
149
+ return by_path.get(app.chosen_path)
150
+
151
+
152
+ def default_startup_resume_deps() -> ResumeDeps:
153
+ """Build the live, Textual-backed :class:`ResumeDeps` the repl runner
154
+ injects into :func:`~induscode.launch.pickers.pick_resume_target` so the
155
+ interactive ``--resume`` picker actually mounts."""
156
+ return StartupResumeDeps()
@@ -0,0 +1,78 @@
1
+ """Slash command groups — the console's command catalog, by topic (M5 wave 1).
2
+
3
+ Port of the TS ``src/console/slash/commands/`` directory plus the catalog
4
+ assembly from ``src/console/slash/builtins.ts``. Each group is its own
5
+ module exporting an ordered command list written against the M1
6
+ :mod:`induscode.console_slash` framework (contracts, ``family_runner``,
7
+ ``info``/``warn``, ``HANDLED``); the assembler (:mod:`.builtins`) splices
8
+ the groups, in order, into the catalog.
9
+
10
+ Landed groups:
11
+
12
+ - :mod:`.transcript` — 11 session-control verbs (``/clear`` ``/new``
13
+ ``/summarize-context`` ``/resume`` ``/session`` ``/branch`` ``/timeline``
14
+ ``/name`` ``/reload`` ``/quit`` ``/exit``).
15
+ - :mod:`.workbench` — 7 picker/help/diagnostics verbs (``/model``
16
+ ``/models-for`` ``/settings`` ``/help`` ``/keys`` ``/whats-new``
17
+ ``/debug``). The catalog assembler calls
18
+ :func:`set_help_registry_provider` with the assembled registry so
19
+ ``/help`` lists dynamic rows too (the late-bound provider replacing the
20
+ TS dynamic-import cycle-breaker).
21
+ - :mod:`.integrations` — 8 auth/bridge/I-O verbs (``/login`` ``/logout``
22
+ ``/mcp`` ``/memory`` ``/composio`` ``/copy`` ``/export`` ``/share``),
23
+ minted per console over an :class:`IntegrationsRuntime` holder.
24
+ - :mod:`.dynamic` — the discovered ``/skill:<name>`` and
25
+ ``/<template-name>`` row builders.
26
+ - :mod:`.builtins` — the explicit :func:`build_catalog` ``(cwd, home)``
27
+ assembly (transcript → workbench → integrations → dynamic; no import-time
28
+ fs scans) plus the lazily-built ``SLASH_COMMANDS`` /
29
+ ``DEFAULT_SLASH_REGISTRY`` defaults, re-exported lazily here.
30
+ """
31
+
32
+ from induscode.console.slash_commands.builtins import (
33
+ build_catalog,
34
+ build_default_registry,
35
+ discover_dynamic_sources,
36
+ reset_default_registry,
37
+ )
38
+ from induscode.console.slash_commands.dynamic import (
39
+ DynamicCommandSources,
40
+ build_dynamic_commands,
41
+ )
42
+ from induscode.console.slash_commands.integrations import (
43
+ IntegrationsRuntime,
44
+ build_integration_commands,
45
+ )
46
+ from induscode.console.slash_commands.transcript import transcript_commands
47
+ from induscode.console.slash_commands.workbench import (
48
+ HelpRegistryProvider,
49
+ set_help_registry_provider,
50
+ workbench_commands,
51
+ )
52
+
53
+ __all__ = [
54
+ "DEFAULT_SLASH_REGISTRY",
55
+ "DynamicCommandSources",
56
+ "HelpRegistryProvider",
57
+ "IntegrationsRuntime",
58
+ "SLASH_COMMANDS",
59
+ "build_catalog",
60
+ "build_default_registry",
61
+ "build_dynamic_commands",
62
+ "build_integration_commands",
63
+ "discover_dynamic_sources",
64
+ "reset_default_registry",
65
+ "set_help_registry_provider",
66
+ "transcript_commands",
67
+ "workbench_commands",
68
+ ]
69
+
70
+
71
+ def __getattr__(name: str) -> object:
72
+ """Lazily re-export the default catalog/registry from :mod:`.builtins`
73
+ (minted on first access, never at import — plan cross-cutting rule 4)."""
74
+ if name in ("SLASH_COMMANDS", "DEFAULT_SLASH_REGISTRY"):
75
+ from induscode.console.slash_commands import builtins as _builtins
76
+
77
+ return getattr(_builtins, name)
78
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")