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,1677 @@
1
+ """ConsoleApp — the top-level Textual application for the interactive console.
2
+
3
+ Rewrite of TS ``src/console/components/TerminalConsole.tsx`` (1089 LOC) as a
4
+ :class:`textual.app.App`, modeled on the framework's ``ui_bridge.app``
5
+ ``InteractiveApp`` (the working reference for the signal→snapshot projection,
6
+ the ``post_message`` relay, the worker patterns, the Esc abort, the exit
7
+ codes, and the exit transcript). This is the surface the host process renders
8
+ to drive a session from a live terminal: it owns one
9
+ :func:`~induscode.console.reducer.console_reducer` store, subscribes to the
10
+ conductor's :data:`~induscode.conductor.SessionSignal` stream and projects
11
+ each signal into reducer events plus retained-widget updates, renders the
12
+ console chrome (:class:`~induscode.console.components.Banner`,
13
+ :class:`~induscode.console.components.StatusBar`) around the framework
14
+ widgets (``MessageList`` / ``StreamingMarkdown`` / ``TaskPanel`` /
15
+ ``PromptEditor``), and derives its ``BINDINGS`` from the pure
16
+ :data:`~induscode.console.input.INTENT_TABLE`.
17
+
18
+ The app stays deliberately thin: every *decision* lives in a pure module (the
19
+ reducer, the intent table, the chord/exit-window machines, the slash
20
+ resolver, the completion providers, the overlay flows). The app holds only
21
+ the live values those pure functions need carried between keystrokes — the
22
+ chord latch, the Ctrl+C exit window, the streaming segments — plus the
23
+ timers, and it wires events in and pushes state out.
24
+
25
+ Port deltas, all locked by the plan (analysis 02 §7 + risks 2–5):
26
+
27
+ - **Streaming-row bookkeeping is REDESIGNED, not copied** (risk 2). The TS
28
+ surface kept a ``liveAnswer``/``answerText`` ref pair per kind with a
29
+ documented first-delta-clipping bug history, and double-rendered streams
30
+ (reducer rows *plus* the MessageList re-reading ``conductor.messages()``).
31
+ Here each streaming kind is one :class:`_LiveSegment` value (row id +
32
+ accumulated text + the retained ``StreamingMarkdown`` widget): a delta with
33
+ no open segment mints a fresh row and widget; a delta with one appends to
34
+ both; ``tool_start`` *settles* the segment (the next answer text starts a
35
+ fresh row — the TS run-on/clipping bug class is structurally impossible);
36
+ ``turn_end``/``fault`` settle and clear the live tail, after which the
37
+ ``MessageList`` (fed from ``conductor.messages()``) is the sole renderer of
38
+ the settled turn. The reducer rows remain the *bookkeeping ledger* exactly
39
+ as in TS (where they were never rendered either — the TS render path was
40
+ MessageList + StreamingMarkdown too) and :meth:`ConsoleApp.stream_parity_report`
41
+ is the parity check the plan asks for: the ledger rows must match what the
42
+ retained widgets were fed.
43
+ - **The editor collapses into the framework.** The TS hand-rolled composer
44
+ (buffer/caret splice, software caret, paste vault, burst debounce,
45
+ bracketed-paste stripping) is the framework ``PromptEditor``/``EditorCore``;
46
+ the console contributes only the autocomplete provider, the submit routing,
47
+ and the app-level chords. The paste-burst machinery and the bracketed-paste
48
+ regex are deleted (Textual delivers one assembled ``events.Paste``).
49
+ - **Viewport math is deleted.** ``transcriptHeight`` / terminal-resize
50
+ tracking / the ``pulse()`` re-render hack die: the ``MessageList`` is a
51
+ ``VerticalScroll`` that owns its viewport, and retained widgets re-render
52
+ from pushed state.
53
+ - **OSC 2 terminal title goes through Textual** (risk 4): the app sets
54
+ ``self.title`` (Textual's driver owns the escape writes); no raw escape
55
+ bytes are emitted, so a redirected run can never leak control sequences.
56
+ - **Chord-latch scope** (documented divergence): TS broke a primed double-tap
57
+ latch on *any* key. Here printable keys are consumed by the editor and
58
+ never reach the app, so the latch is bounded by the
59
+ :data:`~induscode.console.input.CHORD_WINDOW_MS` expiry timer instead. The
60
+ fired outcomes and the window width are identical.
61
+ - **Overlays are awaited flows** (risk 1): an ``overlay:open`` intent (or a
62
+ slash handler's ``open_modal``) dispatches ``modal:open``, runs
63
+ :func:`~induscode.console.overlays.open_overlay` in a worker
64
+ (``push_screen_wait`` demands one), folds the returned
65
+ ``OverlayOutcome.events`` through the reducer, and dispatches
66
+ ``modal:close`` — so ``ConsoleState.modal`` mirrors the screen stack.
67
+ """
68
+
69
+ from __future__ import annotations
70
+
71
+ import os
72
+ import subprocess
73
+ import time
74
+ from collections.abc import Mapping
75
+ from dataclasses import dataclass, replace
76
+ from typing import Any, Callable, ClassVar, Final, Literal, Sequence
77
+
78
+ from rich.text import Text
79
+ from textual.app import App, ComposeResult
80
+ from textual.binding import Binding, BindingType
81
+ from textual.containers import Vertical, VerticalScroll
82
+ from textual.message import Message
83
+ from textual.timer import Timer
84
+ from textual.widgets import Static
85
+
86
+ from indusagi.react_ink import (
87
+ ContextUsage,
88
+ MessageList,
89
+ PendingMessageItem,
90
+ SessionSnapshot,
91
+ SessionStats,
92
+ SessionTokenStats,
93
+ StatusMessage,
94
+ StreamingMarkdown,
95
+ TaskPanel,
96
+ ToolExecutionState,
97
+ theme_variable_defaults,
98
+ )
99
+ from indusagi.react_ink.components.editor import PromptEditor
100
+ from indusagi.react_ink.utils.message_groups import (
101
+ extract_tool_text,
102
+ preview_text,
103
+ safe_stringify,
104
+ )
105
+ from indusagi.ui_bridge.app import exit_transcript_text
106
+
107
+ from induscode.console_slash import SlashContext, SlashRegistry, resolve_slash
108
+ from induscode.kit import open_in_external_editor, read_clipboard_image
109
+ from induscode.workspace.brand import VERSION
110
+
111
+ from .components import Banner, StatusBar
112
+ from .contract import (
113
+ MODAL_KINDS,
114
+ BlockAppend,
115
+ BusySet,
116
+ ConsoleEvent,
117
+ ConsoleState,
118
+ ConsoleTheme,
119
+ ModalClose,
120
+ ModalKind,
121
+ ModalOpen,
122
+ OverlayServices,
123
+ RowsAppend,
124
+ RowsPatch,
125
+ SessionConductor,
126
+ SessionSignal,
127
+ StatusSet,
128
+ Tick,
129
+ ToggleReasoning,
130
+ ViewRow,
131
+ )
132
+ from .input import (
133
+ CHORD_WINDOW_MS,
134
+ INTENT_TABLE,
135
+ NO_CHORD,
136
+ NO_EXIT_WINDOW,
137
+ ChordLatch,
138
+ ConsoleAutocompleteProvider,
139
+ ConsoleIntent,
140
+ ExitWindow,
141
+ advance_chord,
142
+ advance_exit_window,
143
+ create_dir_reader,
144
+ )
145
+ from .overlays import open_overlay
146
+ from .reducer import console_reducer, init_console_state
147
+ from .startup import StartupInputs, StartupMap, gather_startup
148
+ from .theme import resolve_theme
149
+
150
+ __all__ = [
151
+ "ConductorSignalMessage",
152
+ "ConsoleApp",
153
+ "FALLBACK_CONTEXT_WINDOW",
154
+ "TOOL_CARD_PREVIEW_LINES",
155
+ "TRANSCRIPT_MAX_ITEMS",
156
+ "count_providers",
157
+ "fresh_row_id",
158
+ "project_snapshot",
159
+ "read_branch",
160
+ "read_double_escape_action",
161
+ "summarize_tool_args",
162
+ "tool_card_text",
163
+ ]
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Tunables
168
+ # ---------------------------------------------------------------------------
169
+
170
+ #: The context-window size assumed when a model exposes none of its own
171
+ #: (TS ``FALLBACK_CONTEXT_WINDOW``).
172
+ FALLBACK_CONTEXT_WINDOW: Final[int] = 200_000
173
+
174
+ #: The retained transcript window. The TS viewport math (``transcriptHeight``
175
+ #: over the live terminal row count) is deleted — the ``MessageList`` is a
176
+ #: scrollable retained log — so this is simply a prune cap on mounted rows.
177
+ TRANSCRIPT_MAX_ITEMS: Final[int] = 240
178
+
179
+ #: The window/tab title shown when the session carries no name.
180
+ _DEFAULT_TITLE: Final[str] = "indus console"
181
+
182
+ #: How many completion candidates the suggestion overlay lists at once.
183
+ _SUGGESTION_WINDOW: Final[int] = 8
184
+
185
+ #: How many result lines a collapsed tool card paints before deferring the
186
+ #: rest behind the ``… (+N lines, ctrl+o to expand)`` tail. This is the TS
187
+ #: tool-card collapsed budget (``ToolEventBlock`` / ``MessageList`` clamp
188
+ #: ``maxContentLines={10}``), so the live card and the settled transcript
189
+ #: card show the same preview window.
190
+ TOOL_CARD_PREVIEW_LINES: Final[int] = 10
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Pure helpers (ported from the TS module-level functions)
195
+ # ---------------------------------------------------------------------------
196
+
197
+
198
+ def read_branch(cwd: str | None = None) -> str | None:
199
+ """Read the current VCS branch once, returning ``None`` outside a repo.
200
+
201
+ Port of the TS ``readBranch``: shells out to
202
+ ``git rev-parse --abbrev-ref HEAD`` and trims the result; any failure
203
+ (no git, detached, not a repo) collapses to ``None`` so the footer simply
204
+ omits the branch segment rather than throwing.
205
+
206
+ :param cwd: the directory to probe (defaults to the process cwd)
207
+ """
208
+ try:
209
+ out = subprocess.run(
210
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
211
+ cwd=cwd if cwd is not None else os.getcwd(),
212
+ capture_output=True,
213
+ text=True,
214
+ timeout=5,
215
+ )
216
+ except (OSError, subprocess.SubprocessError):
217
+ return None
218
+ if out.returncode != 0:
219
+ return None
220
+ branch = out.stdout.strip()
221
+ return branch if len(branch) > 0 else None
222
+
223
+
224
+ def count_providers(conductor: SessionConductor) -> int:
225
+ """Count the distinct provider ids among the catalog the picker would
226
+ list (TS ``countProviders``)."""
227
+ seen: set[str] = set()
228
+ try:
229
+ for card in conductor.available_models():
230
+ seen.add(card.provider)
231
+ except Exception:
232
+ return 0
233
+ return len(seen)
234
+
235
+
236
+ def read_double_escape_action(
237
+ services: OverlayServices | None,
238
+ ) -> Literal["tree", "fork", "clear"]:
239
+ """Resolve what a double-Escape should do from the preference store,
240
+ normalised to one of the three surface behaviours.
241
+
242
+ Port of the TS ``readDoubleEscapeAction``: reads ``doubleEscapeAction``;
243
+ the legacy ``branch`` / ``rewind`` values map onto the tree / fork
244
+ behaviours respectively. Any read fault, a missing services bundle, or an
245
+ unrecognised value collapses to ``clear`` so the chord always has a
246
+ defined effect.
247
+ """
248
+ if services is None:
249
+ return "clear"
250
+ try:
251
+ raw = services.settings.get("doubleEscapeAction")
252
+ except Exception:
253
+ return "clear"
254
+ if raw in ("tree", "branch"):
255
+ return "tree"
256
+ if raw in ("fork", "rewind"):
257
+ return "fork"
258
+ return "clear"
259
+
260
+
261
+ #: Memoized "model id -> real context window" cache for the by-id fallback
262
+ #: resolver. The full framework catalog (~840 models) is a stable, process-wide
263
+ #: table, so a window once resolved for an id never changes; caching it keeps
264
+ #: ``project_snapshot`` (called on every refresh) from re-walking the catalog.
265
+ _CONTEXT_WINDOW_BY_ID: dict[str, int] = {}
266
+
267
+
268
+ def _context_window_by_id(model_id: str) -> int:
269
+ """Resolve a model's REAL context window from the framework full catalog
270
+ by bare model id, returning ``0`` when no catalog entry matches.
271
+
272
+ This is the by-id safety net for :func:`_resolve_context_window`: the bound
273
+ model object already carries ``contextWindow`` on the live path (the
274
+ matcher binds ``card.model``), but a model object assembled some other way
275
+ (a stub, a partial record) may omit it. Rather than collapse such a model
276
+ onto :data:`FALLBACK_CONTEXT_WINDOW`, we look its window up by id across the
277
+ FULL catalog — the same ``get_card`` the gateway uses — so a real catalog
278
+ model is never reported against the wrong denominator. A genuinely unknown
279
+ id misses here (``0``) and only then falls through to the fallback.
280
+ """
281
+ if not model_id:
282
+ return 0
283
+ cached = _CONTEXT_WINDOW_BY_ID.get(model_id)
284
+ if cached is not None:
285
+ return cached
286
+ window = 0
287
+ try:
288
+ from indusagi.llmgateway.catalog import get_card
289
+
290
+ card = get_card(model_id)
291
+ if card is not None:
292
+ raw = getattr(card, "context_window", None)
293
+ if raw is None:
294
+ raw = getattr(card, "contextWindow", None)
295
+ if isinstance(raw, int) and raw > 0:
296
+ window = raw
297
+ except Exception:
298
+ # The catalog is optional infrastructure; any failure to consult it
299
+ # simply leaves the by-id net empty and lets the caller fall back.
300
+ window = 0
301
+ _CONTEXT_WINDOW_BY_ID[model_id] = window
302
+ return window
303
+
304
+
305
+ def _resolve_context_window(model: Any, model_id: str) -> int:
306
+ """Resolve the context-window denominator for ``ctx:%``.
307
+
308
+ Mirrors the TS ``projectSnapshot`` rule (``model.contextWindow > 0 ?
309
+ model.contextWindow : FALLBACK``) but adds a by-id catalog net BEFORE the
310
+ literal fallback so a real catalog model whose bound object happens to lack
311
+ a positive window still reports its true denominator:
312
+
313
+ 1. the bound model's own ``contextWindow`` (the live path — already correct
314
+ for every catalog model, since the matcher binds the full ``card.model``);
315
+ 2. a by-id lookup over the FULL framework catalog (covers the ~840-model
316
+ fallback catalog, not just the curated cards);
317
+ 3. :data:`FALLBACK_CONTEXT_WINDOW`, hit only for a genuinely unknown id.
318
+ """
319
+ raw_window = getattr(model, "contextWindow", 0) if model is not None else 0
320
+ if isinstance(raw_window, int) and raw_window > 0:
321
+ return raw_window
322
+ by_id = _context_window_by_id(model_id)
323
+ if by_id > 0:
324
+ return by_id
325
+ return FALLBACK_CONTEXT_WINDOW
326
+
327
+
328
+ def project_snapshot(
329
+ conductor: SessionConductor,
330
+ services: OverlayServices | None,
331
+ messages: Sequence[Any],
332
+ cwd: str,
333
+ ) -> SessionSnapshot:
334
+ """Project the conductor's :class:`~induscode.conductor.ConductorState`
335
+ plus the console's runtime handles into the framework
336
+ :class:`~indusagi.react_ink.SessionSnapshot` the Footer / StatusLine /
337
+ TaskPanel strips read.
338
+
339
+ Port of the TS ``projectSnapshot`` as a pure function. The console stores
340
+ no session data of its own, so the snapshot is assembled per refresh from
341
+ the live conductor snapshot (model id, usage, phase) and the runtime
342
+ reads (stats, pending count, the auto-compact preference). Context
343
+ occupancy is the LATEST turn's footprint (``snap.contextTokens``), not
344
+ the cumulative session spend — summing input across every turn would
345
+ inflate ``ctx:%`` far past the real window usage.
346
+ """
347
+ snap = conductor.snapshot()
348
+ model = conductor.model()
349
+ # The denominator is the model's REAL context window: the bound model's own
350
+ # ``contextWindow`` (correct for every catalog model on the live path),
351
+ # else a by-id lookup over the full framework catalog, else the fallback —
352
+ # so haiku reads 200k, opus-4-8 1M, gpt-5.x its own window, never a wrong
353
+ # fixed denominator. ``model.id`` is preferred over ``snap.modelId`` because
354
+ # the latter is the canonical "provider/modelId" key while the catalog is
355
+ # keyed by bare id; both are tried so either spelling resolves.
356
+ model_id = ""
357
+ if model is not None:
358
+ model_id = getattr(model, "id", "") or ""
359
+ context_window = _resolve_context_window(model, model_id)
360
+ used_tokens = snap.contextTokens
361
+ percent = (used_tokens / context_window) * 100 if context_window > 0 else 0.0
362
+ stats = conductor.stats()
363
+ pending = conductor.pending_count()
364
+ # The footer "(auto)" marker mirrors the real auto-compact preference; an
365
+ # absent services bundle (headless / test) keeps the default-on behaviour.
366
+ auto_compact = True
367
+ if services is not None:
368
+ try:
369
+ auto_compact = bool(services.settings.get("autoCompact"))
370
+ except Exception:
371
+ auto_compact = True
372
+ return SessionSnapshot(
373
+ messages=tuple(messages),
374
+ model=model,
375
+ thinkingLevel=conductor.thinking_level(),
376
+ isStreaming=snap.phase == "streaming",
377
+ isCompacting=snap.phase == "condensing",
378
+ isBashRunning=snap.phase == "tooling",
379
+ pendingMessageCount=pending,
380
+ pendingToolCallCount=0,
381
+ sessionId=snap.head.sessionId,
382
+ sessionFile=None,
383
+ sessionName=conductor.session_name(),
384
+ cwd=cwd,
385
+ autoCompactEnabled=auto_compact,
386
+ leafId=snap.head.leaf,
387
+ stats=SessionStats(
388
+ sessionId=stats.sessionId,
389
+ userMessages=stats.userMessages,
390
+ assistantMessages=stats.assistantMessages,
391
+ toolCalls=stats.toolCalls,
392
+ toolResults=stats.toolResults,
393
+ totalMessages=stats.totalMessages,
394
+ tokens=SessionTokenStats(
395
+ input=stats.tokens.input,
396
+ output=stats.tokens.output,
397
+ cacheRead=stats.tokens.cacheRead,
398
+ cacheWrite=stats.tokens.cacheWrite,
399
+ total=stats.tokens.total,
400
+ ),
401
+ cost=stats.cost,
402
+ ),
403
+ contextUsage=ContextUsage(
404
+ tokens=used_tokens,
405
+ contextWindow=context_window,
406
+ percent=percent,
407
+ ),
408
+ )
409
+
410
+
411
+ _ROW_SEED_ALPHABET: Final[str] = "0123456789abcdefghijklmnopqrstuvwxyz"
412
+
413
+
414
+ def fresh_row_id(seed: int) -> str:
415
+ """Mint a unique row id for a freshly opened transcript row (the TS
416
+ ``freshRowId``: a base-36 seed plus a random suffix)."""
417
+ digits = ""
418
+ value = seed
419
+ while True:
420
+ digits = _ROW_SEED_ALPHABET[value % 36] + digits
421
+ value //= 36
422
+ if value == 0:
423
+ break
424
+ suffix = "".join(
425
+ _ROW_SEED_ALPHABET[b % 36] for b in os.urandom(6)
426
+ )
427
+ return f"row-{digits}-{suffix}"
428
+
429
+
430
+ def _now_ms() -> int:
431
+ """Epoch milliseconds (the TS ``Date.now()``)."""
432
+ return int(time.time() * 1000)
433
+
434
+
435
+ def summarize_tool_args(args: Any) -> str:
436
+ """One-line preview of a tool call's arguments — the TS tool-display
437
+ ``fallbackSummary`` semantics: ``previewText(safeStringify(args), 88)``,
438
+ with an empty-object summary collapsing to ``""`` so the card header
439
+ never shows a bare ``{}``."""
440
+ if args is None:
441
+ return ""
442
+ if isinstance(args, Mapping):
443
+ args = dict(args)
444
+ summary = preview_text(safe_stringify(args), 88)
445
+ return "" if summary == "{}" else summary
446
+
447
+
448
+ def tool_card_text(
449
+ adapter: Any,
450
+ *,
451
+ name: str,
452
+ args_summary: str,
453
+ output: str,
454
+ status: Literal["running", "success", "error"],
455
+ expanded: bool,
456
+ preview_lines: int = TOOL_CARD_PREVIEW_LINES,
457
+ ) -> Text:
458
+ """Render one tool execution as a collapsed card (the defect-2 fix).
459
+
460
+ The header line is ``[tool <name>] <arg summary>`` painted through the
461
+ theme adapter (accent name, muted args). A running card shows a dim
462
+ ``running…`` row; a settled card shows the result CLAMPED to
463
+ ``preview_lines`` with a dim ``… (+N lines, ctrl+o to expand)`` tail —
464
+ the same collapsed/expanded discipline as the framework tool cards
465
+ (``view:expandTools`` flips ``expanded`` and the card repaints).
466
+ """
467
+ header = Text()
468
+ header.append_text(adapter.color("accent", f"[tool {name}]"))
469
+ if args_summary:
470
+ header.append(" ")
471
+ header.append_text(adapter.muted(args_summary))
472
+ rows: list[Text] = [header]
473
+
474
+ if status == "running":
475
+ rows.append(adapter.dim(" running…"))
476
+ return Text("\n").join(rows)
477
+
478
+ body = output.strip()
479
+ if not body:
480
+ rows.append(adapter.dim(" (no output)"))
481
+ return Text("\n").join(rows)
482
+
483
+ lines = body.splitlines()
484
+ visible = lines if expanded else lines[:preview_lines]
485
+ hidden = len(lines) - len(visible)
486
+ for line in visible:
487
+ if status == "error":
488
+ rows.append(adapter.color("error", f" {line}"))
489
+ else:
490
+ rows.append(Text(f" {line}"))
491
+ if hidden > 0:
492
+ rows.append(adapter.dim(f" … (+{hidden} lines, ctrl+o to expand)"))
493
+ return Text("\n").join(rows)
494
+
495
+
496
+ # ---------------------------------------------------------------------------
497
+ # The conductor-signal relay
498
+ # ---------------------------------------------------------------------------
499
+
500
+
501
+ class ConductorSignalMessage(Message):
502
+ """One conductor :data:`SessionSignal` marshalled into Textual's message
503
+ pump (the analogue of the reference app's ``AgentEventMessage``)."""
504
+
505
+ def __init__(self, signal: SessionSignal) -> None:
506
+ self.signal = signal
507
+ super().__init__()
508
+
509
+
510
+ # ---------------------------------------------------------------------------
511
+ # Live streaming segments (the risk-2 redesign)
512
+ # ---------------------------------------------------------------------------
513
+
514
+
515
+ @dataclass(slots=True)
516
+ class _LiveSegment:
517
+ """One open streaming segment: the ledger row it grows, the accumulated
518
+ text fed into it, and the retained ``StreamingMarkdown`` it paints into.
519
+
520
+ Replaces the TS ``liveAnswer``/``answerText`` ref pair. A closed segment
521
+ has every field reset; a delta arriving on a closed segment mints a fresh
522
+ row + widget, so a post-tool narration can never run on into (or clip the
523
+ first delta of) the previous one.
524
+ """
525
+
526
+ row_id: str | None = None
527
+ text: str = ""
528
+ widget: StreamingMarkdown | None = None
529
+
530
+ def reset(self) -> None:
531
+ self.row_id = None
532
+ self.text = ""
533
+ self.widget = None
534
+
535
+
536
+ @dataclass(slots=True)
537
+ class _ToolCard:
538
+ """One live tool-execution card mounted in the streaming tail.
539
+
540
+ ``tool_start`` mints it (header + ``running…``), ``tool_end`` settles it
541
+ with the clamped result, ``view:expandTools`` repaints it expanded, and
542
+ the turn settling drops it (the ``MessageList`` then renders the settled
543
+ tool call/result cards from ``conductor.messages()``).
544
+ """
545
+
546
+ name: str
547
+ args_summary: str = ""
548
+ output: str = ""
549
+ status: Literal["running", "success", "error"] = "running"
550
+ widget: Static | None = None
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # The app
555
+ # ---------------------------------------------------------------------------
556
+
557
+ #: Chords that must be claimed at app priority. ``ctrl+u`` is editor-consumed
558
+ #: (``deleteToLineStart``) below priority; ``ctrl+c`` must shadow Textual's
559
+ #: built-in App quit binding. Everything else bubbles from the focused editor
560
+ #: untouched, so plain (non-priority) bindings suffice.
561
+ _PRIORITY_CHORDS: Final[frozenset[str]] = frozenset({"ctrl+c", "ctrl+u"})
562
+
563
+
564
+ def _build_bindings() -> list[BindingType]:
565
+ """Derive the app ``BINDINGS`` rows from the pure
566
+ :data:`~induscode.console.input.INTENT_TABLE` — one parameterised
567
+ ``intent`` action per chord, so the chord→verb matrix stays data."""
568
+ return [
569
+ Binding(
570
+ key,
571
+ f"intent({key!r})",
572
+ show=False,
573
+ priority=key in _PRIORITY_CHORDS,
574
+ )
575
+ for key in INTENT_TABLE
576
+ ]
577
+
578
+
579
+ class ConsoleApp(App[int]):
580
+ """The interactive console surface: banner, transcript, live streaming
581
+ tail, task panel, prompt editor, suggestion overlay, and status strip —
582
+ driven by conductor signals folded through the console reducer into
583
+ retained widgets."""
584
+
585
+ TITLE = _DEFAULT_TITLE
586
+
587
+ # The prompt editor ALWAYS holds keyboard focus while no modal is open, so
588
+ # typing always lands in the composer (owner bug: text could not be typed
589
+ # because Textual auto-focused the FIRST focusable widget — the transcript
590
+ # scroll — over the on_mount editor focus, and a click on the transcript
591
+ # then trapped focus there). ``AUTO_FOCUS`` is Textual's canonical screen-
592
+ # mount focus seam: it focuses ``#editor`` when the screen mounts instead of
593
+ # defaulting to ``"*"`` (the first focusable widget). The transcript and the
594
+ # live tail are additionally made non-focusable in ``compose`` so they can
595
+ # never win focus at all; keyboard scroll is handled by the app intents and
596
+ # mouse-wheel scroll still works on a non-focusable scroll view.
597
+ AUTO_FOCUS: ClassVar[str] = "#editor"
598
+
599
+ # The console owns ctrl+p (model rotation); Textual's palette would
600
+ # otherwise shadow it.
601
+ ENABLE_COMMAND_PALETTE: ClassVar[bool] = False
602
+
603
+ # Mouse text-selection is OFF (the Ink console had none): Textual's
604
+ # screen-level selection drag asserts `content_widget.parent` is a
605
+ # Widget, which fires when the drag resolves to the Screen itself
606
+ # (its parent is the App) — textual/screen.py `_forward_event`,
607
+ # `assert isinstance(content_widget.parent, Widget)`. The app-level
608
+ # ALLOW_SELECT gate is checked on every MouseDown before selection
609
+ # starts, so disabling it here removes the whole crash path.
610
+ ALLOW_SELECT: ClassVar[bool] = False
611
+
612
+ CSS = """
613
+ Screen {
614
+ padding: 0 1;
615
+ }
616
+ /* The scrolling surface fills every row above the docked chrome: the
617
+ transcript (banner + settled rows) takes the leftover height, and the
618
+ live tail grows beneath it (capped, and self-scrolling) so the editor
619
+ and the ctx footer never get pushed off-screen. */
620
+ #transcript {
621
+ height: 1fr;
622
+ }
623
+ #live-tail {
624
+ height: auto;
625
+ max-height: 50%;
626
+ }
627
+ /* The bottom chrome — task panel, prompt editor, suggestion dropdown, and
628
+ the status/ctx strip — is one container DOCKED to the bottom edge, so
629
+ the input field and the ctx footer are ALWAYS pinned at the bottom of
630
+ the screen regardless of how long the conversation grows (parity with
631
+ the TS surface: scrolling transcript on top, fixed input + footer
632
+ below). Docking the whole strip (rather than each piece) reserves a
633
+ single bottom band and keeps the pieces stacked in document order. */
634
+ #chrome {
635
+ dock: bottom;
636
+ height: auto;
637
+ }
638
+ /* The prompt editor MUST be sized here explicitly. The framework
639
+ ``PromptEditor`` (a Textual ``TextArea``) ships ``height: auto;
640
+ max-height: 30%`` in its DEFAULT_CSS and inherits ``TextArea``'s
641
+ ``tall`` border (2 gutter rows). Left as-is, that box COLLAPSES to its
642
+ border inside the ``height: auto`` docked ``#chrome``: the percentage
643
+ ``max-height: 30%`` resolves against the parent's degenerate auto
644
+ height, the content viewport is clamped to 0 visible rows, and the
645
+ editor renders only its top+bottom border edges with NO content row —
646
+ typed text and the cursor are invisible (owner-confirmed bug). The fix
647
+ is to pin a deterministic row budget: ``min-height: 3`` guarantees the
648
+ single-line composer always has its 1 content row between the 2 border
649
+ rows (so the caret and glyphs are visible the moment you type), while
650
+ ``height: auto`` with a FIXED-CELL ``max-height`` (not a percentage)
651
+ lets the box grow for soft-wrapped multi-line input up to a cap without
652
+ re-triggering the percentage-against-auto collapse. */
653
+ #editor {
654
+ height: auto;
655
+ min-height: 3;
656
+ max-height: 12;
657
+ }
658
+ #suggestions {
659
+ height: auto;
660
+ margin: 0 0 0 2;
661
+ }
662
+ """
663
+
664
+ BINDINGS: ClassVar[list[BindingType]] = _build_bindings()
665
+
666
+ def __init__(
667
+ self,
668
+ conductor: SessionConductor,
669
+ *,
670
+ theme: ConsoleTheme,
671
+ slash: SlashRegistry,
672
+ services: OverlayServices | None = None,
673
+ initial_input: str | None = None,
674
+ verbose: bool = False,
675
+ on_exit: Callable[[], None] | None = None,
676
+ cwd: str | None = None,
677
+ ) -> None:
678
+ # Set before super().__init__(): App.__init__ builds the stylesheet,
679
+ # which calls get_theme_variable_defaults() below.
680
+ self._console_theme = theme
681
+ super().__init__()
682
+ self._conductor = conductor
683
+ self._slash = slash
684
+ self._services = services
685
+ self._initial_input = initial_input
686
+ self._verbose = verbose
687
+ self._on_exit = on_exit
688
+ self._cwd = cwd if cwd is not None else os.getcwd()
689
+
690
+ # The single reducer store (UI-local state only; session data is read
691
+ # from the conductor on each refresh).
692
+ self._state: ConsoleState = init_console_state(scheme=theme.scheme)
693
+
694
+ # Live (non-reducer) values carried between keystrokes.
695
+ self._chord: ChordLatch = NO_CHORD
696
+ self._chord_timer: Timer | None = None
697
+ self._exit_window: ExitWindow = NO_EXIT_WINDOW
698
+
699
+ # The open streaming segments, one per signal kind.
700
+ self._live_answer = _LiveSegment()
701
+ self._live_reason = _LiveSegment()
702
+ self._row_seed = 0
703
+
704
+ # Live tool-execution cards, keyed by tool-call id.
705
+ self._tool_executions: dict[str, ToolExecutionState] = {}
706
+
707
+ # The live tool cards mounted in the streaming tail, keyed by
708
+ # tool-call id (cleared when the turn settles into the MessageList).
709
+ self._tool_cards: dict[str, _ToolCard] = {}
710
+
711
+ # Whether collapsed tool output is shown in full (Ctrl+O).
712
+ self._expand_tools = False
713
+
714
+ # The repository branch, read once; the footer omits it outside a repo.
715
+ self._branch = read_branch(self._cwd)
716
+
717
+ # The startup chrome, gathered once at mount (pure fs probes).
718
+ self._startup_map = self._gather_startup()
719
+
720
+ # Widgets are reachable only after compose; guarded by this flag.
721
+ self._view_ready = False
722
+ self._unsubscribe: Callable[[], None] | None = None
723
+
724
+ # -- theme wiring -------------------------------------------------------
725
+
726
+ @property
727
+ def console_theme(self) -> ConsoleTheme:
728
+ """The fully-resolved console theme currently applied."""
729
+ return self._console_theme
730
+
731
+ @property
732
+ def console_state(self) -> ConsoleState:
733
+ """The current reducer state (read-only; dispatch to change it)."""
734
+ return self._state
735
+
736
+ def get_theme_variable_defaults(self) -> dict[str, str]:
737
+ theme: ConsoleTheme | None = getattr(self, "_console_theme", None)
738
+ if theme is None:
739
+ return {}
740
+ return theme_variable_defaults(theme.bundle.textual_theme)
741
+
742
+ # -- startup gathering ----------------------------------------------------
743
+
744
+ def _setting(self, key: str, fallback: Any) -> Any:
745
+ """A guarded preference read: any fault collapses to the fallback."""
746
+ if self._services is None:
747
+ return fallback
748
+ try:
749
+ return self._services.settings.get(key)
750
+ except Exception:
751
+ return fallback
752
+
753
+ def _gather_startup(self) -> StartupMap:
754
+ last_seen = self._setting("lastSeenVersion", "")
755
+ return gather_startup(
756
+ StartupInputs(
757
+ cwd=self._cwd,
758
+ home=os.path.expanduser("~"),
759
+ version=VERSION,
760
+ last_seen_version=last_seen if isinstance(last_seen, str) else "",
761
+ )
762
+ )
763
+
764
+ # -- composition ----------------------------------------------------------
765
+
766
+ def compose(self) -> ComposeResult:
767
+ adapter = self._console_theme.adapter
768
+ snap = self._conductor.snapshot()
769
+
770
+ # Policy (parity with the TS surface): the full masthead shows on
771
+ # every launch unless the quiet preference asks otherwise, and the
772
+ # changelog is never surfaced at startup — /whats-new is on demand.
773
+ quiet = bool(self._setting("quietStartup", False)) and not self._verbose
774
+ sweep = (
775
+ bool(self._setting("logoSweep", False))
776
+ and not bool(self._setting("reducedMotion", False))
777
+ and os.isatty(1)
778
+ )
779
+
780
+ # The banner is SCROLLBACK CONTENT, not fixed chrome (defect-3 fix,
781
+ # parity with the TS inline frames): it is mounted as the first item
782
+ # INSIDE the scrolling transcript, so it scrolls away naturally as
783
+ # messages accumulate. The MessageList's row reconciliation only
784
+ # touches the row widgets it minted, so the banner is never pruned,
785
+ # and fresh rows mount after it; the anchored scroll keeps the log
786
+ # pinned to the bottom once content overflows.
787
+ transcript = MessageList(
788
+ adapter,
789
+ show_thinking=self._state.show_reasoning,
790
+ show_images=self._state.show_images,
791
+ expand_tool_outputs=self._expand_tools,
792
+ max_items=TRANSCRIPT_MAX_ITEMS,
793
+ id="transcript",
794
+ )
795
+ # The transcript is a focusable VerticalScroll by default; make it
796
+ # non-focusable so it can NEVER hold keyboard focus (the editor always
797
+ # does). Otherwise Textual would auto-focus it as the first focusable
798
+ # widget, and a click on the history would trap focus on the scroll
799
+ # container — typing would then do nothing. Mouse-wheel scrolling still
800
+ # works on a non-focusable scroll view; keyboard scroll is the app's own
801
+ # intents. (The framework's own dialogs use this same
802
+ # ``widget.can_focus = False`` instance toggle.)
803
+ transcript.can_focus = False
804
+ with transcript:
805
+ yield Banner(
806
+ adapter,
807
+ model_id=snap.modelId,
808
+ workspace=self._cwd,
809
+ version=VERSION,
810
+ verbose=self._verbose,
811
+ quiet=quiet,
812
+ compact=False,
813
+ sweep=sweep,
814
+ startup=self._startup_map,
815
+ id="banner",
816
+ )
817
+
818
+ # The live streaming tail: StreamingMarkdown widgets and tool cards are
819
+ # mounted in here per narration segment and cleared once the turn
820
+ # settles into the MessageList above. It is an *anchored* VerticalScroll
821
+ # (not a plain Vertical) so a long mid-stream narration keeps its newest
822
+ # line in view within its capped band instead of clipping off the bottom
823
+ # — the same scroll-to-newest discipline the transcript above uses.
824
+ live_tail = VerticalScroll(id="live-tail")
825
+ # Like the transcript: a non-focusable scroll view so it can never steal
826
+ # keyboard focus from the editor (mouse-wheel scroll still works).
827
+ live_tail.can_focus = False
828
+ yield live_tail
829
+
830
+ # The bottom chrome, docked as one band (see the CSS note on #chrome):
831
+ # the task panel sits just above the editor (TS parity — the task strip
832
+ # renders above the input), the editor + suggestion dropdown next, and
833
+ # the status/ctx strip pinned at the very bottom. Stacked in document
834
+ # order inside the docked container so the ctx footer is the last row.
835
+ with Vertical(id="chrome"):
836
+ yield TaskPanel(
837
+ adapter,
838
+ snapshot=None,
839
+ tool_executions={},
840
+ expand_tool_outputs=self._expand_tools,
841
+ pending_messages=(),
842
+ id="tasks",
843
+ )
844
+
845
+ editor = PromptEditor(
846
+ autocomplete_provider=ConsoleAutocompleteProvider(
847
+ self._slash, create_dir_reader(self._cwd)
848
+ ),
849
+ id="editor",
850
+ )
851
+ yield editor
852
+
853
+ suggestions = Static("", id="suggestions")
854
+ suggestions.display = False
855
+ yield suggestions
856
+
857
+ yield StatusBar(
858
+ adapter,
859
+ snapshot=None,
860
+ branch=self._branch,
861
+ provider_count=count_providers(self._conductor),
862
+ status=None,
863
+ id="statusbar",
864
+ )
865
+
866
+ # -- lifecycle --------------------------------------------------------------
867
+
868
+ def on_mount(self) -> None:
869
+ # Register every shipped scheme so `scheme:set` (and the theme
870
+ # picker's live preview) is a native `app.theme = name` retheme.
871
+ from .theme import THEMES
872
+
873
+ for theme in THEMES.values():
874
+ self.register_theme(theme.bundle.textual_theme)
875
+ self.theme = self._console_theme.scheme
876
+
877
+ self._view_ready = True
878
+ self._unsubscribe = self._conductor.subscribe(self._relay_signal)
879
+ # Anchor the live streaming tail so newly mounted segments/tool cards
880
+ # keep the newest line in view (the transcript MessageList anchors
881
+ # itself in its own on_mount).
882
+ self.query_one("#live-tail", VerticalScroll).anchor()
883
+ # Focus the editor robustly. ``AUTO_FOCUS = "#editor"`` already focuses
884
+ # it on screen mount; this immediate call plus a post-refresh re-assert
885
+ # belt-and-suspenders against any late auto-focus so the composer is the
886
+ # focused widget the instant the surface appears.
887
+ self._focus_editor()
888
+ self.call_after_refresh(self._focus_editor)
889
+ self._refresh_view()
890
+
891
+ # The signed-in account label for the personalized welcome line.
892
+ if self._services is not None:
893
+ self.run_worker(self._load_welcome_account(), group="console-startup")
894
+
895
+ # An optional first user turn submitted on mount.
896
+ if self._initial_input is not None and self._initial_input.strip():
897
+ self.run_worker(
898
+ self._run_turn(self._initial_input),
899
+ group="console-submit",
900
+ exclusive=False,
901
+ )
902
+
903
+ def on_unmount(self) -> None:
904
+ if self._unsubscribe is not None:
905
+ self._unsubscribe()
906
+ self._unsubscribe = None
907
+
908
+ # -- keyboard focus discipline -------------------------------------------
909
+
910
+ def _modal_open(self) -> bool:
911
+ """True when a ModalScreen overlay owns the screen — overlays own
912
+ their own focus, so the console must not yank it back to the editor."""
913
+ return self.screen is not self.screen_stack[0]
914
+
915
+ def _focus_editor(self) -> None:
916
+ """Return keyboard focus to the prompt editor unless a modal overlay
917
+ owns the screen. The editor must always be the focused widget while no
918
+ dialog is open, so typing always lands in the composer. Guarded so it
919
+ is inert before compose and a no-op while an overlay is up."""
920
+ if not self._view_ready or self._modal_open():
921
+ return
922
+ try:
923
+ editor = self.query_one("#editor", PromptEditor)
924
+ except Exception:
925
+ return
926
+ if self.focused is not editor:
927
+ editor.focus()
928
+
929
+ def on_click(self, event: object) -> None:
930
+ """A click anywhere in the console (history, live tail, banner, the
931
+ chrome strips) returns focus to the editor — so clicking the transcript
932
+ can never trap focus on a scroll container and leave typing dead. The
933
+ transcript and live tail are already non-focusable; this is the
934
+ belt-and-suspenders catch for any other stray click target. No-op while
935
+ an overlay owns the screen (those dialogs manage their own focus)."""
936
+ self._focus_editor()
937
+
938
+ async def _load_welcome_account(self) -> None:
939
+ services = self._services
940
+ if services is None:
941
+ return
942
+ try:
943
+ provider = services.settings.get("defaultProvider")
944
+ account = await services.vault.default_account(provider)
945
+ except Exception:
946
+ return # the banner falls back to "Welcome back!"
947
+ if account:
948
+ self.query_one("#banner", Banner).account = account
949
+
950
+ # -- the reducer store --------------------------------------------------------
951
+
952
+ def dispatch(self, event: ConsoleEvent) -> None:
953
+ """Fold one event through the console reducer and push the fresh
954
+ state into the retained widgets (the retained-mode replacement for
955
+ the TS re-render)."""
956
+ self._state = console_reducer(self._state, event)
957
+ if event.type == "scheme:set":
958
+ self._apply_scheme(self._state.scheme)
959
+ self._refresh_view()
960
+
961
+ def _apply_scheme(self, scheme: str) -> None:
962
+ """Re-skin the surface for a committed scheme: swap the resolved
963
+ console theme, the Textual theme (CSS variables), the banner painter,
964
+ and the chrome strips' painters; the transcript is rebuilt so settled
965
+ rows repaint under the new adapter."""
966
+ theme = resolve_theme(scheme)
967
+ self._console_theme = theme
968
+ try:
969
+ self.theme = theme.scheme
970
+ except Exception:
971
+ pass # an unregistered name keeps the current Textual theme
972
+ if not self._view_ready:
973
+ return
974
+ adapter = theme.adapter
975
+ self.query_one("#banner", Banner).theme = adapter
976
+ bar = self.query_one("#statusbar", StatusBar)
977
+ bar.status_line.theme = adapter
978
+ bar.footer.theme = adapter
979
+ self.query_one("#tasks", TaskPanel).theme = adapter
980
+ transcript = self.query_one("#transcript", MessageList)
981
+ # The MessageList captures its painter at construction; swap it and
982
+ # rebuild so history repaints (a deliberate reach-in — the framework
983
+ # exposes no setter, and re-mounting the list would lose scroll).
984
+ transcript._theme = adapter # noqa: SLF001
985
+ self._repaint_tool_cards()
986
+ self._rebuild_transcript()
987
+
988
+ def _rebuild_transcript(self) -> None:
989
+ """Drop and re-mount every transcript row (used when a render flag —
990
+ show_thinking / expand_tool_outputs / the painter — changes)."""
991
+ transcript = self.query_one("#transcript", MessageList)
992
+ transcript.show_thinking = self._state.show_reasoning
993
+ transcript.show_images = self._state.show_images
994
+ transcript.expand_tool_outputs = self._expand_tools
995
+ transcript.sync((), ())
996
+ self._refresh_view()
997
+
998
+ # -- view refresh ---------------------------------------------------------------
999
+
1000
+ def _scroll_to_bottom(self) -> None:
1001
+ """Keep the scrolling surface pinned to the newest content.
1002
+
1003
+ Both scroll regions are *anchored* (the transcript MessageList and the
1004
+ live tail), so they follow growth automatically while the user is at the
1005
+ bottom and stay put once the user scrolls up. This belt-and-suspenders
1006
+ call re-asserts the bottom on every content event — a settled row synced
1007
+ into the transcript, a streamed delta, a tool card, a status change, and
1008
+ right after submit — so the default and post-submit landing is always the
1009
+ newest line, even when an event grows content without moving scroll_y
1010
+ (which is what re-arms the anchor). It never yanks a user who has
1011
+ scrolled up: ``scroll_end`` respects a released anchor, and the anchor is
1012
+ only re-armed once the user returns to the bottom themselves.
1013
+ """
1014
+ if not self._view_ready:
1015
+ return
1016
+ for selector in ("#transcript", "#live-tail"):
1017
+ try:
1018
+ region = self.query_one(selector, VerticalScroll)
1019
+ except Exception:
1020
+ continue
1021
+ if region.is_anchored and not region._anchor_released: # noqa: SLF001
1022
+ region.scroll_end(animate=False, immediate=True)
1023
+
1024
+ def _refresh_view(self) -> None:
1025
+ """Push the current conductor snapshot + reducer state into the
1026
+ retained widgets."""
1027
+ if not self._view_ready:
1028
+ return
1029
+ conductor = self._conductor
1030
+ messages = list(conductor.messages())
1031
+ snapshot = project_snapshot(conductor, self._services, messages, self._cwd)
1032
+
1033
+ self.query_one("#transcript", MessageList).sync(
1034
+ messages, list(self._state.blocks)
1035
+ )
1036
+
1037
+ # The pending-input queue, projected to the framework's panel shape.
1038
+ pending: list[PendingMessageItem] = [
1039
+ PendingMessageItem(
1040
+ id=f"pending-{index}-{entry.mode}",
1041
+ mode=entry.mode,
1042
+ text=entry.text,
1043
+ source="session",
1044
+ )
1045
+ for index, entry in enumerate(conductor.pending_inputs())
1046
+ ]
1047
+ tasks = self.query_one("#tasks", TaskPanel)
1048
+ tasks.snapshot = snapshot
1049
+ tasks.tool_executions = dict(self._tool_executions)
1050
+ tasks.expand_tool_outputs = self._expand_tools
1051
+ tasks.pending_messages = pending
1052
+
1053
+ self.query_one("#statusbar", StatusBar).update_state(
1054
+ snapshot,
1055
+ branch=self._branch,
1056
+ provider_count=count_providers(conductor),
1057
+ status=self._state.status,
1058
+ )
1059
+
1060
+ self.query_one("#banner", Banner).model_id = conductor.snapshot().modelId
1061
+
1062
+ # Terminal title (OSC 2) through Textual's title machinery — never a
1063
+ # raw escape write (risk 4).
1064
+ name = conductor.session_name()
1065
+ self.title = name if name else _DEFAULT_TITLE
1066
+
1067
+ # Land the view at the newest content (a synced settled row, a status
1068
+ # change, a post-submit refresh) unless the user has scrolled up.
1069
+ self._scroll_to_bottom()
1070
+
1071
+ # -- conductor signal folding -------------------------------------------------------
1072
+
1073
+ def _relay_signal(self, signal: SessionSignal) -> None:
1074
+ """Marshal one conductor signal into the app's message pump. The hub
1075
+ emits synchronously on the app's own loop (the submit worker drives
1076
+ the conductor there), so posting directly is loop-safe."""
1077
+ self.post_message(ConductorSignalMessage(signal))
1078
+
1079
+ async def on_conductor_signal_message(self, message: ConductorSignalMessage) -> None:
1080
+ """Project one :data:`SessionSignal` into reducer events + retained
1081
+ widget updates — the port of the TS subscription switch."""
1082
+ signal = message.signal
1083
+ kind = signal.kind
1084
+
1085
+ if kind == "prompt":
1086
+ # The user's turn is already readable from conductor.messages();
1087
+ # the trailing refresh re-syncs the MessageList so it echoes the
1088
+ # user row immediately rather than on the first reply token.
1089
+ pass
1090
+ elif kind == "text":
1091
+ await self._stream_delta("answer", signal.delta)
1092
+ elif kind == "thinking":
1093
+ await self._stream_delta("reason", signal.delta)
1094
+ elif kind == "tool_start":
1095
+ # End the current narration segment so assistant text after this
1096
+ # tool call starts a fresh row + widget instead of running on.
1097
+ await self._close_live_segments(drop=False)
1098
+ self.dispatch(
1099
+ RowsAppend(
1100
+ row=ViewRow(
1101
+ id=f"tool-{signal.id}",
1102
+ kind="toolRun",
1103
+ text=signal.name,
1104
+ run_id=signal.id,
1105
+ )
1106
+ )
1107
+ )
1108
+ # The signal carries only (id, name); the call's arguments are
1109
+ # read from the just-settled assistant message so the card (and
1110
+ # the TaskPanel) can show the arg summary instead of "".
1111
+ args = self._tool_call_args(signal.id)
1112
+ args_summary = summarize_tool_args(args)
1113
+ self._tool_executions[signal.id] = ToolExecutionState(
1114
+ toolCallId=signal.id,
1115
+ toolName=signal.name,
1116
+ argsText=args_summary,
1117
+ status="running",
1118
+ outputText="",
1119
+ args=args,
1120
+ updatedAt=_now_ms(),
1121
+ )
1122
+ await self._open_tool_card(signal.id, signal.name, args_summary)
1123
+ elif kind == "tool_end":
1124
+ self.dispatch(
1125
+ RowsPatch(id=f"tool-{signal.id}", text="done" if signal.ok else "failed")
1126
+ )
1127
+ # The result message is already appended when this signal is
1128
+ # handled (the relay defers through the message pump), so the
1129
+ # card body is the real tool output, clamped.
1130
+ output = self._tool_result_text(signal.id)
1131
+ prior = self._tool_executions.get(signal.id)
1132
+ if prior is not None:
1133
+ self._tool_executions[signal.id] = replace(
1134
+ prior,
1135
+ status="success" if signal.ok else "error",
1136
+ outputText=output,
1137
+ updatedAt=_now_ms(),
1138
+ )
1139
+ self._settle_tool_card(signal.id, ok=signal.ok, output=output)
1140
+ elif kind == "turn_end":
1141
+ await self._close_live_segments(drop=True)
1142
+ self.dispatch(BusySet(busy=False))
1143
+ self.dispatch(Tick())
1144
+ elif kind == "compacted":
1145
+ self.dispatch(
1146
+ StatusSet(status=StatusMessage(kind="info", text="Conversation condensed."))
1147
+ )
1148
+ elif kind == "persisted":
1149
+ self.dispatch(Tick())
1150
+ elif kind == "fault":
1151
+ await self._close_live_segments(drop=True)
1152
+ self.dispatch(BusySet(busy=False))
1153
+ self.dispatch(
1154
+ StatusSet(status=StatusMessage(kind="error", text=signal.fault.message))
1155
+ )
1156
+ elif kind == "queue":
1157
+ # Depth is re-read from pending_inputs() on refresh.
1158
+ pass
1159
+ elif kind == "idle":
1160
+ self.dispatch(BusySet(busy=False))
1161
+
1162
+ self._refresh_view()
1163
+
1164
+ # -- streaming segments ---------------------------------------------------------------
1165
+
1166
+ def _segment(self, kind: Literal["answer", "reason"]) -> _LiveSegment:
1167
+ return self._live_answer if kind == "answer" else self._live_reason
1168
+
1169
+ async def _stream_delta(self, kind: Literal["answer", "reason"], delta: str) -> None:
1170
+ """Fold one streamed delta into the segment's ledger row and its
1171
+ retained ``StreamingMarkdown``. Reason deltas paint into the live
1172
+ tail only while ``show_reasoning`` is on (the ledger row is kept
1173
+ regardless, exactly as TS dispatched reason rows unconditionally)."""
1174
+ if not delta:
1175
+ return
1176
+ segment = self._segment(kind)
1177
+ if segment.row_id is None:
1178
+ # A new narration segment begins: mint a fresh row seeded with
1179
+ # this first delta (the TS first-delta-clipping regression is
1180
+ # structurally impossible — a closed segment has no carried text).
1181
+ segment.row_id = fresh_row_id(self._row_seed)
1182
+ self._row_seed += 1
1183
+ segment.text = delta
1184
+ self.dispatch(
1185
+ RowsAppend(
1186
+ row=ViewRow(
1187
+ id=segment.row_id,
1188
+ kind="answer" if kind == "answer" else "reason",
1189
+ text=segment.text,
1190
+ )
1191
+ )
1192
+ )
1193
+ else:
1194
+ segment.text += delta
1195
+ self.dispatch(RowsPatch(id=segment.row_id, text=segment.text))
1196
+
1197
+ paint = kind == "answer" or self._state.show_reasoning
1198
+ if not paint:
1199
+ return
1200
+ if segment.widget is None:
1201
+ segment.widget = StreamingMarkdown(dim=kind == "reason")
1202
+ await self.query_one("#live-tail", VerticalScroll).mount(segment.widget)
1203
+ await segment.widget.write(delta)
1204
+ self._scroll_to_bottom()
1205
+
1206
+ async def _close_live_segments(self, *, drop: bool) -> None:
1207
+ """Settle both open segments. With ``drop`` the live tail is emptied
1208
+ (the settled turn is now rendered by the MessageList); without it the
1209
+ settled widgets stay visible mid-turn (text preceding a tool call)."""
1210
+ for segment in (self._live_answer, self._live_reason):
1211
+ if segment.widget is not None:
1212
+ await segment.widget.settle()
1213
+ segment.reset()
1214
+ if drop:
1215
+ self._tool_cards.clear()
1216
+ await self.query_one("#live-tail", VerticalScroll).remove_children()
1217
+
1218
+ # -- tool cards -----------------------------------------------------------
1219
+
1220
+ def _tool_call_args(self, call_id: str) -> Any:
1221
+ """The arguments of the tool call ``call_id``, read from the settled
1222
+ assistant message (the ``tool_start`` signal carries only id+name)."""
1223
+ for message in reversed(list(self._conductor.messages())):
1224
+ if getattr(message, "role", None) != "assistant":
1225
+ continue
1226
+ for part in getattr(message, "content", ()) or ():
1227
+ if (
1228
+ getattr(part, "type", None) == "toolCall"
1229
+ and getattr(part, "id", None) == call_id
1230
+ ):
1231
+ return getattr(part, "arguments", None)
1232
+ return None
1233
+
1234
+ def _tool_result_text(self, call_id: str) -> str:
1235
+ """The plain text of the tool result for ``call_id`` (empty when the
1236
+ result message has not landed — the card then shows ``(no output)``)."""
1237
+ for message in reversed(list(self._conductor.messages())):
1238
+ if (
1239
+ getattr(message, "role", None) == "toolResult"
1240
+ and getattr(message, "toolCallId", None) == call_id
1241
+ ):
1242
+ return extract_tool_text(getattr(message, "content", None), False)
1243
+ return ""
1244
+
1245
+ def _tool_card_render(self, card: _ToolCard) -> Text:
1246
+ return tool_card_text(
1247
+ self._console_theme.adapter,
1248
+ name=card.name,
1249
+ args_summary=card.args_summary,
1250
+ output=card.output,
1251
+ status=card.status,
1252
+ expanded=self._expand_tools,
1253
+ )
1254
+
1255
+ async def _open_tool_card(self, call_id: str, name: str, args_summary: str) -> None:
1256
+ """Mount the running card for one tool execution into the live tail
1257
+ (the defect-2 fix: a styled, clamped card instead of a raw dump)."""
1258
+ card = _ToolCard(name=name, args_summary=args_summary)
1259
+ card.widget = Static(self._tool_card_render(card), classes="tool-card")
1260
+ self._tool_cards[call_id] = card
1261
+ await self.query_one("#live-tail", VerticalScroll).mount(card.widget)
1262
+ self._scroll_to_bottom()
1263
+
1264
+ def _settle_tool_card(self, call_id: str, *, ok: bool, output: str) -> None:
1265
+ """Settle the live card with its clamped result (collapsed unless
1266
+ Ctrl+O has expanded tool output)."""
1267
+ card = self._tool_cards.get(call_id)
1268
+ if card is None:
1269
+ return
1270
+ card.status = "success" if ok else "error"
1271
+ card.output = output
1272
+ if card.widget is not None:
1273
+ card.widget.update(self._tool_card_render(card))
1274
+ self._scroll_to_bottom()
1275
+
1276
+ def _repaint_tool_cards(self) -> None:
1277
+ """Repaint every mounted live card (expand toggle / scheme switch)."""
1278
+ for card in self._tool_cards.values():
1279
+ if card.widget is not None:
1280
+ card.widget.update(self._tool_card_render(card))
1281
+
1282
+ def stream_parity_report(self) -> list[str]:
1283
+ """The risk-2 parity check: every open segment's ledger row must
1284
+ exist and carry exactly the text fed into its retained widget, and
1285
+ every tracked tool execution must have its ``toolRun`` ledger row.
1286
+ Returns human-readable violations (empty == in parity)."""
1287
+ problems: list[str] = []
1288
+ for label, segment in (("answer", self._live_answer), ("reason", self._live_reason)):
1289
+ if segment.row_id is None:
1290
+ continue
1291
+ row = next((r for r in self._state.rows if r.id == segment.row_id), None)
1292
+ if row is None:
1293
+ problems.append(f"live {label} row {segment.row_id!r} missing from ledger")
1294
+ elif row.text != segment.text:
1295
+ problems.append(
1296
+ f"live {label} row {segment.row_id!r} ledger text diverged from stream"
1297
+ )
1298
+ for run_id in self._tool_executions:
1299
+ if not any(
1300
+ r.kind == "toolRun" and r.run_id == run_id for r in self._state.rows
1301
+ ):
1302
+ problems.append(f"tool execution {run_id!r} has no toolRun ledger row")
1303
+ return problems
1304
+
1305
+ # -- submit routing -------------------------------------------------------------------
1306
+
1307
+ def on_prompt_editor_submitted(self, message: PromptEditor.Submitted) -> None:
1308
+ message.stop()
1309
+ if not message.text.strip():
1310
+ return
1311
+ self.run_worker(
1312
+ self._handle_submit(message.text), group="console-submit", exclusive=False
1313
+ )
1314
+
1315
+ async def _handle_submit(self, raw: str) -> None:
1316
+ """Route one committed line — verbatim TS order: the ``!``/``!!``
1317
+ bash escape, then slash resolution, then a plain prompt. The editor
1318
+ has already expanded paste markers and cleared its buffer."""
1319
+ trimmed = raw.strip()
1320
+ if not trimmed:
1321
+ return
1322
+ self.query_one("#editor", PromptEditor).add_to_history(trimmed)
1323
+
1324
+ # A leading "!" escapes to the shell rather than the model. "!!" runs
1325
+ # the command but keeps its output out of the conversation context.
1326
+ if trimmed.startswith("!"):
1327
+ exclude = trimmed.startswith("!!")
1328
+ command = trimmed[2 if exclude else 1 :].strip()
1329
+ if not command:
1330
+ return
1331
+ note = ViewRow(
1332
+ id=fresh_row_id(self._row_seed), kind="notice", text=f"! {command}"
1333
+ )
1334
+ self._row_seed += 1
1335
+ self.dispatch(RowsAppend(row=note))
1336
+ self.dispatch(
1337
+ StatusSet(status=StatusMessage(kind="busy", text=f"Running: {command}"))
1338
+ )
1339
+ try:
1340
+ from induscode.conductor import ExecuteBashOptions
1341
+
1342
+ outcome = await self._conductor.execute_bash(
1343
+ command, ExecuteBashOptions(excludeFromContext=exclude)
1344
+ )
1345
+ self.dispatch(
1346
+ StatusSet(
1347
+ status=StatusMessage(
1348
+ kind="info" if outcome.exitCode == 0 else "error",
1349
+ text=f"Shell exited {outcome.exitCode}.",
1350
+ )
1351
+ )
1352
+ )
1353
+ except Exception as err:
1354
+ self.dispatch(
1355
+ StatusSet(
1356
+ status=StatusMessage(kind="error", text=str(err) or repr(err))
1357
+ )
1358
+ )
1359
+ return
1360
+
1361
+ # Try a slash command next.
1362
+ resolution = resolve_slash(trimmed, self._slash)
1363
+ if resolution.kind == "match":
1364
+ ctx = SlashContext(
1365
+ args=resolution.args,
1366
+ conductor=self._conductor,
1367
+ dispatch=self.dispatch,
1368
+ open_modal=self._open_modal,
1369
+ close_modal=lambda: self.dispatch(ModalClose()),
1370
+ request_exit=self._request_exit,
1371
+ set_status=lambda status: self.dispatch(StatusSet(status=status)),
1372
+ set_buffer=self._set_buffer,
1373
+ append_block=lambda block: self.dispatch(BlockAppend(block=block)),
1374
+ )
1375
+ outcome = await resolution.command.run(ctx)
1376
+ if outcome.kind == "prompt":
1377
+ await self._run_turn(outcome.text)
1378
+ return
1379
+ if resolution.kind == "miss":
1380
+ self.dispatch(
1381
+ StatusSet(
1382
+ status=StatusMessage(
1383
+ kind="warning", text=f"Unknown command: /{resolution.name}"
1384
+ )
1385
+ )
1386
+ )
1387
+ return
1388
+
1389
+ # A plain prompt.
1390
+ await self._run_turn(raw)
1391
+
1392
+ async def _run_turn(self, text: str) -> None:
1393
+ """Append the prompt ledger row, flip busy, and drive the conductor
1394
+ to settlement. A busy conductor queues the input itself (the
1395
+ queued-when-busy semantics live in ``SessionConductor.submit``)."""
1396
+ row = ViewRow(id=fresh_row_id(self._row_seed), kind="prompt", text=text)
1397
+ self._row_seed += 1
1398
+ self.dispatch(RowsAppend(row=row))
1399
+ self.dispatch(BusySet(busy=True))
1400
+ try:
1401
+ await self._conductor.submit(text)
1402
+ except Exception as err:
1403
+ self.dispatch(BusySet(busy=False))
1404
+ self.dispatch(
1405
+ StatusSet(status=StatusMessage(kind="error", text=str(err) or repr(err)))
1406
+ )
1407
+ self._refresh_view()
1408
+ # Settle at the newest line with the composer focused, ready for the
1409
+ # next turn (the refresh above already re-asserted the bottom).
1410
+ if self._view_ready and self.screen is self.screen_stack[0]:
1411
+ self.query_one("#editor", PromptEditor).focus()
1412
+
1413
+ def _set_buffer(self, value: str) -> None:
1414
+ self.query_one("#editor", PromptEditor).set_text(value)
1415
+
1416
+ def _request_exit(self) -> None:
1417
+ if self._on_exit is not None:
1418
+ try:
1419
+ self._on_exit()
1420
+ except Exception:
1421
+ pass
1422
+ self.exit(0)
1423
+
1424
+ # -- overlays ---------------------------------------------------------------------------
1425
+
1426
+ def _open_modal(self, kind: str, payload: object | None = None) -> None:
1427
+ """The ``SlashContext.open_modal`` seam: validate the kind and raise
1428
+ the overlay (an unknown kind is ignored, as the TS host rendered
1429
+ nothing for one)."""
1430
+ if kind in MODAL_KINDS:
1431
+ self._raise_overlay(kind, payload) # type: ignore[arg-type]
1432
+
1433
+ def _raise_overlay(self, kind: ModalKind, payload: object | None = None) -> None:
1434
+ """Dispatch ``modal:open`` and run the awaited overlay flow in a
1435
+ worker (``push_screen_wait`` demands one)."""
1436
+ if kind == "none":
1437
+ return
1438
+ self.dispatch(ModalOpen(kind=kind, payload=payload))
1439
+ self.run_worker(
1440
+ self._overlay_flow(kind, payload), group="console-overlay", exclusive=False
1441
+ )
1442
+
1443
+ async def _overlay_flow(self, kind: ModalKind, payload: object | None) -> None:
1444
+ try:
1445
+ outcome = await open_overlay(self, kind, payload, self._services)
1446
+ for event in outcome.events:
1447
+ self.dispatch(event)
1448
+ finally:
1449
+ self.dispatch(ModalClose())
1450
+
1451
+ # -- suggestion overlay -------------------------------------------------------------------
1452
+
1453
+ def on_prompt_editor_suggestions_changed(
1454
+ self, message: PromptEditor.SuggestionsChanged
1455
+ ) -> None:
1456
+ message.stop()
1457
+ panel = self.query_one("#suggestions", Static)
1458
+ adapter = self._console_theme.adapter
1459
+ lines: list[Text] = []
1460
+ for index, item in enumerate(message.items[:_SUGGESTION_WINDOW]):
1461
+ chosen = index == message.selected_index
1462
+ line = Text("▸ " if chosen else " ")
1463
+ label = adapter.color("accent", item.label) if chosen else Text(item.label)
1464
+ line.append_text(label)
1465
+ if item.description:
1466
+ line.append(" ")
1467
+ line.append_text(adapter.dim(item.description))
1468
+ lines.append(line)
1469
+ panel.update(Text("\n").join(lines))
1470
+ panel.display = len(lines) > 0
1471
+
1472
+ def on_prompt_editor_suggestions_closed(
1473
+ self, message: PromptEditor.SuggestionsClosed
1474
+ ) -> None:
1475
+ message.stop()
1476
+ panel = self.query_one("#suggestions", Static)
1477
+ panel.update("")
1478
+ panel.display = False
1479
+
1480
+ # -- intents -------------------------------------------------------------------------------
1481
+
1482
+ def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
1483
+ """Gate the intent bindings while a dialog owns the screen — the TS
1484
+ ``useInput`` was inactive whenever a modal was up. Ctrl+C stays live
1485
+ (clear-then-exit must always be reachable); everything else declines
1486
+ so the key forwards on to the dialog's own bindings."""
1487
+ if (
1488
+ action == "intent"
1489
+ and self.screen is not self.screen_stack[0]
1490
+ and parameters
1491
+ and parameters[0] != "ctrl+c"
1492
+ ):
1493
+ return False
1494
+ return super().check_action(action, parameters)
1495
+
1496
+ def _arm_chord(self, next_latch: ChordLatch) -> None:
1497
+ """Carry the chord latch and (re)arm its expiry timer (the TS
1498
+ ``armChord`` + ``CHORD_WINDOW_MS`` timeout)."""
1499
+ self._chord = next_latch
1500
+ if self._chord_timer is not None:
1501
+ self._chord_timer.stop()
1502
+ self._chord_timer = None
1503
+ if next_latch.armed is not None:
1504
+ self._chord_timer = self.set_timer(
1505
+ CHORD_WINDOW_MS / 1000, self._expire_chord
1506
+ )
1507
+
1508
+ def _expire_chord(self) -> None:
1509
+ self._chord = NO_CHORD
1510
+ self._chord_timer = None
1511
+
1512
+ def action_intent(self, key: str) -> None:
1513
+ """Dispatch one app-level chord: look the intent up in the pure
1514
+ :data:`INTENT_TABLE`, advance the double-tap latch, and run the verb
1515
+ — the port of the TS ``useInput`` switch, minus the editor-delegated
1516
+ verbs the framework editor consumes before keys reach the app."""
1517
+ intent = INTENT_TABLE.get(key)
1518
+ if intent is None:
1519
+ return
1520
+
1521
+ step = advance_chord(self._chord, intent)
1522
+ self._arm_chord(step.next)
1523
+ if step.fired == "clear×2":
1524
+ self._request_exit()
1525
+ return
1526
+ if step.fired == "dismiss×2":
1527
+ self._fire_double_escape()
1528
+ return
1529
+
1530
+ self._dispatch_intent(intent)
1531
+
1532
+ def _fire_double_escape(self) -> None:
1533
+ """Double-Escape is configurable: the preference selects whether the
1534
+ chord opens the transcript tree, the prior-turn fork picker, or wipes
1535
+ the composer buffer."""
1536
+ action = read_double_escape_action(self._services)
1537
+ if action == "tree":
1538
+ self._raise_overlay("tree")
1539
+ elif action == "fork":
1540
+ self._raise_overlay("userTurns")
1541
+ else:
1542
+ self._set_buffer("")
1543
+
1544
+ def _dispatch_intent(self, intent: ConsoleIntent) -> None:
1545
+ verb = intent.verb
1546
+
1547
+ if verb == "flow:interrupt":
1548
+ # Ctrl+C: clear-then-exit. The pure machine decides; the app
1549
+ # performs (abort a busy turn / clear the composer / arm the
1550
+ # window / exit on the second empty-buffer press inside it).
1551
+ editor = self.query_one("#editor", PromptEditor)
1552
+ step = advance_exit_window(
1553
+ self._exit_window,
1554
+ busy=self._state.busy,
1555
+ buffer_empty=len(editor.get_text()) == 0,
1556
+ now_ms=time.monotonic() * 1000,
1557
+ )
1558
+ self._exit_window = step.next
1559
+ if step.action == "abort":
1560
+ self._conductor.abort()
1561
+ elif step.action == "clear":
1562
+ editor.set_text("")
1563
+ elif step.action == "exit":
1564
+ self._request_exit()
1565
+ return
1566
+
1567
+ if verb == "flow:dismiss":
1568
+ # A single Escape: drop an overlay (defensive — the dialog's own
1569
+ # Esc normally races this away), else stop a running turn.
1570
+ if self._state.modal.kind != "none":
1571
+ self.dispatch(ModalClose())
1572
+ elif self._state.busy:
1573
+ self._conductor.abort()
1574
+ return
1575
+
1576
+ if verb == "edit:clearLine":
1577
+ # Ctrl+U wipes the whole composer line (TS `buffer:set ""`); the
1578
+ # chord latch above turns a double-tap into a request to exit.
1579
+ self._set_buffer("")
1580
+ return
1581
+
1582
+ if verb == "flow:suspend":
1583
+ # Background this process via job control; the shell foregrounds
1584
+ # it again on `fg`, where Textual re-attaches to the terminal.
1585
+ try:
1586
+ self.action_suspend_process()
1587
+ except Exception:
1588
+ pass # headless / unsupported drivers cannot suspend
1589
+ return
1590
+
1591
+ if verb == "flow:cycleModel":
1592
+ cycle = getattr(self._conductor, "cycle_model", None)
1593
+ if callable(cycle):
1594
+ try:
1595
+ cycle("")
1596
+ except Exception:
1597
+ pass
1598
+ self._refresh_view()
1599
+ return
1600
+
1601
+ if verb == "model:cycleThinking":
1602
+ level = self._conductor.cycle_thinking_level()
1603
+ self.dispatch(
1604
+ StatusSet(
1605
+ status=StatusMessage(kind="info", text=f"Reasoning effort: {level}.")
1606
+ )
1607
+ )
1608
+ return
1609
+
1610
+ if verb == "view:toggleReasoning":
1611
+ self.dispatch(ToggleReasoning())
1612
+ self._rebuild_transcript()
1613
+ return
1614
+
1615
+ if verb == "view:expandTools":
1616
+ self._expand_tools = not self._expand_tools
1617
+ self._repaint_tool_cards()
1618
+ self._rebuild_transcript()
1619
+ return
1620
+
1621
+ if verb == "queue:dequeue":
1622
+ restored = self._conductor.dequeue_last()
1623
+ if restored is not None:
1624
+ self._set_buffer(restored)
1625
+ else:
1626
+ self.dispatch(
1627
+ StatusSet(
1628
+ status=StatusMessage(
1629
+ kind="warning", text="No queued message to restore."
1630
+ )
1631
+ )
1632
+ )
1633
+ return
1634
+
1635
+ if verb == "input:pasteImage":
1636
+ path = read_clipboard_image()
1637
+ if path is not None:
1638
+ editor = self.query_one("#editor", PromptEditor)
1639
+ current = editor.get_text()
1640
+ sep = " " if current and not current[-1].isspace() else ""
1641
+ editor.insert_text_at_cursor(f"{sep}{path}")
1642
+ else:
1643
+ self.dispatch(
1644
+ StatusSet(
1645
+ status=StatusMessage(
1646
+ kind="warning", text="No image on the clipboard."
1647
+ )
1648
+ )
1649
+ )
1650
+ return
1651
+
1652
+ if verb == "input:externalEditor":
1653
+ editor = self.query_one("#editor", PromptEditor)
1654
+ try:
1655
+ with self.suspend():
1656
+ edited = open_in_external_editor(editor.get_text())
1657
+ except Exception:
1658
+ return # suspend unsupported (headless): leave the buffer be
1659
+ if edited is not None:
1660
+ editor.set_text(edited)
1661
+ return
1662
+
1663
+ if verb == "overlay:open":
1664
+ if intent.overlay is not None and intent.overlay != "none":
1665
+ self._open_modal(intent.overlay)
1666
+ return
1667
+
1668
+ # "none" and anything editor-delegated: inert at the app layer.
1669
+
1670
+ # -- exit transcript ---------------------------------------------------------------------------
1671
+
1672
+ def exit_transcript(self) -> Text | None:
1673
+ """The accumulated conversation as plain Rich text, printed by the
1674
+ mount into real terminal scrollback once the alternate screen is gone
1675
+ (the framework's exit-transcript pattern — Textual erases the
1676
+ alternate screen on exit, unlike the inline Ink frames)."""
1677
+ return exit_transcript_text(list(self._conductor.messages()))