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,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}")
|