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,132 @@
1
+ """NDJSON framer — the concrete, separator-safe line transport
2
+ (port of TS ``src/channels/framer.ts``).
3
+
4
+ Implements the :class:`~induscode.channels.contract.NdjsonFramer` surface
5
+ declared in the contract: a pure encoder (:func:`encode_line`) and a
6
+ pull-model async-generator decoder (:func:`decode_lines`). Both halves are
7
+ I/O-free seams over an injected stream, so the server, the driver, and the
8
+ oneshot channel share one correct framing implementation.
9
+
10
+ The correctness pin: U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR)
11
+ are legal inside a JSON string but a downstream splitter that treats them as
12
+ line boundaries corrupts the frame. :func:`encode_line` escapes both to their
13
+ backslash-u forms, and :func:`decode_lines` splits strictly on the line feed
14
+ (U+000A) only — so any value the encoder emits round-trips through the
15
+ decoder unchanged regardless of payload content.
16
+
17
+ Port notes
18
+ ----------
19
+ - Python's ``json.dumps`` does **not** escape U+2028/U+2029 (plan rule 7;
20
+ with ``ensure_ascii=False`` they pass through raw, exactly like TS
21
+ ``JSON.stringify``) — the explicit escape is kept verbatim. ``separators``
22
+ are the compact ``(",", ":")`` pair so the wire bytes match
23
+ ``JSON.stringify``.
24
+ - Byte chunks are decoded with ``codecs.getincrementaldecoder("utf-8")``
25
+ (plan rule 8) so a multi-byte rune split across two chunks is reassembled
26
+ rather than corrupted — the analogue of the TS streaming ``TextDecoder``.
27
+ Each :func:`decode_lines` call creates a **fresh** decoder (the TS
28
+ module-level shared decoder was safe only because one reader owns a
29
+ stream; the per-call decoder removes even that coupling).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import codecs
35
+ import json
36
+ from collections.abc import AsyncGenerator
37
+ from typing import Any, Final
38
+
39
+ from induscode.channels.contract import NdjsonFramer, ReadableChunks
40
+
41
+ __all__ = [
42
+ "decode_lines",
43
+ "encode_line",
44
+ "ndjson_framer",
45
+ ]
46
+
47
+
48
+ #: Line feed (U+000A): the one and only frame boundary.
49
+ _LINE_FEED: Final[str] = "\n"
50
+
51
+ #: LINE SEPARATOR (U+2028) — legal in a JSON string, breaks naive splitters.
52
+ _LINE_SEPARATOR: Final[str] = "\u2028"
53
+
54
+ #: PARAGRAPH SEPARATOR (U+2029) — legal in a JSON string, breaks splitters.
55
+ _PARAGRAPH_SEPARATOR: Final[str] = "\u2029"
56
+
57
+ #: The escaped form of U+2028 that survives line splitting.
58
+ _LINE_SEPARATOR_ESCAPE: Final[str] = "\\u2028"
59
+
60
+ #: The escaped form of U+2029 that survives line splitting.
61
+ _PARAGRAPH_SEPARATOR_ESCAPE: Final[str] = "\\u2029"
62
+
63
+
64
+ def _escape_separators(json_text: str) -> str:
65
+ """Replace each U+2028 / U+2029 with its backslash-u escape.
66
+
67
+ Operates on the already-serialized JSON text, so the two separators only
68
+ ever appear inside string literals; rewriting them to their escape
69
+ sequences yields an equivalent JSON document with no raw line/paragraph
70
+ separators left.
71
+ """
72
+ return json_text.replace(_LINE_SEPARATOR, _LINE_SEPARATOR_ESCAPE).replace(
73
+ _PARAGRAPH_SEPARATOR, _PARAGRAPH_SEPARATOR_ESCAPE
74
+ )
75
+
76
+
77
+ def encode_line(value: Any) -> str:
78
+ """Encode one value as a single, separator-safe NDJSON line.
79
+
80
+ Serializes ``value`` with ``json.dumps`` (compact separators, non-ASCII
81
+ left raw — the ``JSON.stringify`` wire shape), escapes the two Unicode
82
+ line separators, and appends exactly one line feed. The result contains
83
+ no raw U+2028 / U+2029 and exactly one terminating ``\\n``, so a
84
+ strictly-newline splitter recovers it intact.
85
+
86
+ :param value: any JSON-serializable value (a request, a reply, a signal)
87
+ :returns: the encoded line, terminated by a single ``\\n``
88
+ """
89
+ return _escape_separators(json.dumps(value, ensure_ascii=False, separators=(",", ":"))) + _LINE_FEED
90
+
91
+
92
+ async def decode_lines(stream: ReadableChunks) -> AsyncGenerator[Any, None]:
93
+ """Decode a chunk stream into a stream of parsed JSON values.
94
+
95
+ Pull model: buffers incoming text, splits on ``\\n`` only, parses each
96
+ complete line, and yields the parsed value. A partial trailing line is
97
+ held until its newline arrives; an empty (or whitespace-only) line is
98
+ skipped. A trailing unterminated line at end-of-stream is parsed if it
99
+ carries content, so a non-newline-terminated final frame is not silently
100
+ dropped. Byte chunks flow through an incremental UTF-8 decoder so a rune
101
+ split across chunk boundaries is reassembled.
102
+
103
+ :param stream: the raw chunk stream (stdin, a child pipe, an in-memory pipe)
104
+ """
105
+ decoder = codecs.getincrementaldecoder("utf-8")()
106
+ buffer = ""
107
+
108
+ async for chunk in stream:
109
+ buffer += chunk if isinstance(chunk, str) else decoder.decode(chunk)
110
+
111
+ newline_at = buffer.find(_LINE_FEED)
112
+ while newline_at != -1:
113
+ line = buffer[:newline_at]
114
+ buffer = buffer[newline_at + 1 :]
115
+ if len(line.strip()) > 0:
116
+ yield json.loads(line)
117
+ newline_at = buffer.find(_LINE_FEED)
118
+
119
+ # Flush any decoder-internal state, then emit a final unterminated frame.
120
+ buffer += decoder.decode(b"", final=True)
121
+ if len(buffer.strip()) > 0:
122
+ yield json.loads(buffer)
123
+
124
+
125
+ #: The bundled framer pair: the canonical :class:`NdjsonFramer` instance.
126
+ #: Frozen so consumers share one implementation by reference rather than
127
+ #: re-deriving framing at each call site; an embedder may still inject an
128
+ #: alternate framer via the driver / server options.
129
+ ndjson_framer: Final[NdjsonFramer] = NdjsonFramer(
130
+ encode_line=encode_line,
131
+ decode_lines=decode_lines,
132
+ )
@@ -0,0 +1,50 @@
1
+ """Link channel — public barrel (port of TS ``src/channels/link/index.ts``).
2
+
3
+ The link channel is the long-lived, bidirectional half of the channels
4
+ subsystem: a JSON-RPC 2.0 server that dispatches framed requests through an
5
+ op registry as concurrent tasks (``server``), a generated client built from a
6
+ ``__getattr__`` trap rather than hand-written methods (``driver``), and the
7
+ ask / tell dialog bridge that both ends share (``dialog``). Consumers import
8
+ the link surface from ``induscode.channels.link`` rather than the individual
9
+ modules.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from induscode.channels.link.dialog import (
15
+ DIALOG_TABLE,
16
+ DialogBridgeDeps,
17
+ DialogSpec,
18
+ LinkedDialogBridge,
19
+ create_dialog_bridge,
20
+ make_dialog_methods,
21
+ )
22
+ from induscode.channels.link.driver import (
23
+ LinkClient,
24
+ LinkDriverHandle,
25
+ LinkDriverIo,
26
+ LinkRequestError,
27
+ create_link_driver,
28
+ )
29
+ from induscode.channels.link.server import (
30
+ LinkServer,
31
+ LinkServerIo,
32
+ create_link_server,
33
+ )
34
+
35
+ __all__ = [
36
+ "DIALOG_TABLE",
37
+ "DialogBridgeDeps",
38
+ "DialogSpec",
39
+ "LinkClient",
40
+ "LinkDriverHandle",
41
+ "LinkDriverIo",
42
+ "LinkRequestError",
43
+ "LinkServer",
44
+ "LinkServerIo",
45
+ "LinkedDialogBridge",
46
+ "create_dialog_bridge",
47
+ "create_link_driver",
48
+ "create_link_server",
49
+ "make_dialog_methods",
50
+ ]
@@ -0,0 +1,246 @@
1
+ """Dialog bridge — the ask / tell seam over the link transport
2
+ (port of TS ``src/channels/link/dialog.ts``).
3
+
4
+ The agent (and the extensions it hosts) needs to interact with whoever drives
5
+ the channel: ask a multiple-choice question, confirm a destructive action,
6
+ prompt for free text, flash a notice, set a status line. Rather than a
7
+ per-interaction handler ladder, the whole surface reduces to two primitives:
8
+
9
+ - :meth:`LinkedDialogBridge.ask` — one generic round-trip: emit an ``ask``
10
+ frame, suspend until a correlated answer arrives, and resolve with its
11
+ value (or a caller-supplied fallback when the deadline lapses or the driver
12
+ dismisses the request).
13
+ - :meth:`LinkedDialogBridge.tell` — one generic fire-and-forget: emit a
14
+ ``tell`` frame and return immediately.
15
+
16
+ Concrete dialog methods (select / confirm / input / notify / status / title)
17
+ are expressed as a *data table* of named entries keyed by interaction kind,
18
+ not as bespoke method bodies. :func:`make_dialog_methods` closes that table
19
+ over a single bridge so a caller gets named conveniences while the bridge
20
+ keeps the lone round-trip / fire-and-forget implementation.
21
+
22
+ Port notes
23
+ ----------
24
+ - The TS promise + ``setTimeout`` pending entry becomes an
25
+ :class:`asyncio.Future` + ``loop.call_later`` timer; timers never keep the
26
+ loop alive on their own (the TS ``unref`` concern does not arise).
27
+ - The TS closure-object bridge becomes the :class:`LinkedDialogBridge` class
28
+ (same four-method surface: ask / tell / deliver / drain).
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import asyncio
34
+ import time
35
+ from dataclasses import dataclass
36
+ from types import MappingProxyType
37
+ from typing import Any, Callable, Final, Literal, Mapping
38
+
39
+ from induscode.channels.contract import (
40
+ AskAnswer,
41
+ ChannelTimings,
42
+ DialogBridge,
43
+ NdjsonFramer,
44
+ WritableLine,
45
+ mint_wire_id,
46
+ )
47
+
48
+ __all__ = [
49
+ "DIALOG_TABLE",
50
+ "DialogBridgeDeps",
51
+ "DialogSpec",
52
+ "LinkedDialogBridge",
53
+ "create_dialog_bridge",
54
+ "make_dialog_methods",
55
+ ]
56
+
57
+
58
+ #: The correlation-id prefix every ask frame carries (TS ``"ask-"``).
59
+ _ASK_ID_PREFIX: Final[str] = "ask-"
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class _Pending:
64
+ """One suspended ask, awaiting its correlated answer."""
65
+
66
+ #: Settles the awaiting future with the answer value (or the fallback).
67
+ future: asyncio.Future[Any]
68
+ #: Timer that fires the fallback if no answer arrives in time.
69
+ timer: asyncio.TimerHandle
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class DialogBridgeDeps:
74
+ """Everything :func:`create_dialog_bridge` needs to emit frames and time
75
+ out."""
76
+
77
+ #: Sink the ask/tell frames are written to (already framed).
78
+ out: WritableLine
79
+ #: Framer used to encode each frame to a line.
80
+ framer: NdjsonFramer
81
+ #: Timings; only ``dialogMs`` is consulted here.
82
+ timings: ChannelTimings
83
+
84
+
85
+ class LinkedDialogBridge:
86
+ """A :class:`DialogBridge` whose pending answers are fed by an external
87
+ reader.
88
+
89
+ The bridge itself does not read the stream — the server owns the single
90
+ decode loop and routes any inbound answer frame to :meth:`deliver`. This
91
+ keeps one reader over the transport (the server) rather than competing
92
+ consumers.
93
+
94
+ ``ask`` writes an ask frame, registers the pending resolver under a fresh
95
+ id, and arms a fallback timer; :meth:`deliver` settles the matching
96
+ pending resolver when the answer returns. ``tell`` writes a tell frame
97
+ and returns at once. No stdio, no global state — every dependency is
98
+ injected.
99
+ """
100
+
101
+ __slots__ = ("_deps", "_pending", "_seq")
102
+
103
+ def __init__(self, deps: DialogBridgeDeps) -> None:
104
+ self._deps = deps
105
+ self._pending: dict[str, _Pending] = {}
106
+ self._seq = 0
107
+
108
+ def _emit(self, frame: Mapping[str, Any]) -> None:
109
+ self._deps.out.write(self._deps.framer.encode_line(frame))
110
+
111
+ def _next_ask_id(self) -> str:
112
+ """Mint a fresh, process-unique correlation id for an ask."""
113
+ self._seq += 1
114
+ return mint_wire_id(_ASK_ID_PREFIX, int(time.time() * 1000), self._seq)
115
+
116
+ async def ask(self, kind: str, payload: Any, fallback: Any) -> Any:
117
+ """Emit an ask frame and resolve with the matching answer value, or
118
+ ``fallback`` when no answer arrives before ``dialogMs`` lapses."""
119
+ ask_id = self._next_ask_id()
120
+ frame: dict[str, Any] = {"type": "ask", "id": ask_id, "kind": kind}
121
+ if payload is not None: # JSON.stringify dropped the undefined payload
122
+ frame["payload"] = payload
123
+
124
+ loop = asyncio.get_running_loop()
125
+ future: asyncio.Future[Any] = loop.create_future()
126
+
127
+ def on_timeout() -> None:
128
+ self._pending.pop(ask_id, None)
129
+ if not future.done():
130
+ future.set_result(fallback)
131
+
132
+ timer = loop.call_later(self._deps.timings.dialogMs / 1000, on_timeout)
133
+ self._pending[ask_id] = _Pending(future=future, timer=timer)
134
+ self._emit(frame)
135
+ return await future
136
+
137
+ def tell(self, kind: str, payload: Any = None) -> None:
138
+ """Emit a tell frame and return immediately."""
139
+ frame: dict[str, Any] = {"type": "tell", "kind": kind}
140
+ if payload is not None:
141
+ frame["payload"] = payload
142
+ self._emit(frame)
143
+
144
+ def deliver(self, answer: AskAnswer) -> bool:
145
+ """Resolve the pending ask that minted ``answer.id``.
146
+
147
+ A no-op when no ask is pending under that id (a late or duplicate
148
+ answer), so a misbehaving driver cannot throw into the read loop.
149
+
150
+ :param answer: the inbound answer frame
151
+ :returns: whether a pending ask was settled by this answer
152
+ """
153
+ entry = self._pending.pop(answer.id, None)
154
+ if entry is None:
155
+ return False
156
+ entry.timer.cancel()
157
+ if not entry.future.done():
158
+ entry.future.set_result(answer.value)
159
+ return True
160
+
161
+ def drain(self) -> None:
162
+ """Settle every still-pending ask with ``None`` (used on shutdown),
163
+ so no awaiting handler is left suspended when the link closes."""
164
+ for entry in self._pending.values():
165
+ entry.timer.cancel()
166
+ if not entry.future.done():
167
+ entry.future.set_result(None)
168
+ self._pending.clear()
169
+
170
+
171
+ def create_dialog_bridge(deps: DialogBridgeDeps) -> LinkedDialogBridge:
172
+ """Construct a :class:`LinkedDialogBridge` over an injected sink + framer."""
173
+ return LinkedDialogBridge(deps)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # Data-driven concrete dialog methods
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ @dataclass(frozen=True, slots=True)
182
+ class DialogSpec:
183
+ """One concrete dialog method expressed as data, not code.
184
+
185
+ ``kind`` is the wire interaction kind; ``mode`` selects which primitive
186
+ carries it (a round-trip ``ask`` or a fire-and-forget ``tell``);
187
+ ``fallback`` is the value a round-trip resolves with on timeout /
188
+ dismissal (unused for ``tell``). The whole dialog surface is this table —
189
+ adding an interaction is adding a row.
190
+ """
191
+
192
+ #: The wire interaction kind (the ``Ask.kind`` / ``Tell.kind``).
193
+ kind: str
194
+ #: Whether the method round-trips for an answer or is fire-and-forget.
195
+ mode: Literal["ask", "tell"]
196
+ #: The value an ``ask`` resolves with when no answer arrives in time.
197
+ fallback: Any = None
198
+
199
+
200
+ #: The canonical dialog table: every concrete interaction the channel
201
+ #: exposes, each one row of data over the two primitives.
202
+ #:
203
+ #: Replaces a copied switch of bespoke UI-context methods: the bridge has
204
+ #: exactly one round-trip and one fire-and-forget body, and this table names
205
+ #: the kinds.
206
+ DIALOG_TABLE: Final[Mapping[str, DialogSpec]] = MappingProxyType(
207
+ {
208
+ "select": DialogSpec(kind="select", mode="ask", fallback=None),
209
+ "confirm": DialogSpec(kind="confirm", mode="ask", fallback=False),
210
+ "input": DialogSpec(kind="input", mode="ask", fallback=None),
211
+ "editor": DialogSpec(kind="editor", mode="ask", fallback=None),
212
+ "notify": DialogSpec(kind="notify", mode="tell"),
213
+ "status": DialogSpec(kind="status", mode="tell"),
214
+ "title": DialogSpec(kind="title", mode="tell"),
215
+ }
216
+ )
217
+
218
+
219
+ def make_dialog_methods(bridge: DialogBridge) -> dict[str, Callable[..., Any]]:
220
+ """Close the :data:`DIALOG_TABLE` over one :class:`DialogBridge` into
221
+ named convenience methods.
222
+
223
+ Each ``ask`` row becomes a thunk that round-trips through ``bridge.ask``
224
+ with the row fallback (call it with an optional payload and await the
225
+ result); each ``tell`` row becomes a thunk that fires through
226
+ ``bridge.tell``. There is no per-method choreography — the bodies are
227
+ generated from the table.
228
+
229
+ :param bridge: the round-trip / fire-and-forget primitive pair
230
+ :returns: the named method map projected from the table
231
+ """
232
+ methods: dict[str, Callable[..., Any]] = {}
233
+ for name, spec in DIALOG_TABLE.items():
234
+ if spec.mode == "ask":
235
+
236
+ def ask_thunk(payload: Any = None, *, _spec: DialogSpec = spec) -> Any:
237
+ return bridge.ask(_spec.kind, payload, _spec.fallback)
238
+
239
+ methods[name] = ask_thunk
240
+ else:
241
+
242
+ def tell_thunk(payload: Any = None, *, _spec: DialogSpec = spec) -> None:
243
+ bridge.tell(_spec.kind, payload)
244
+
245
+ methods[name] = tell_thunk
246
+ return methods