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
induscode/console/app.py
ADDED
|
@@ -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()))
|