tactus 0.32.2__py3-none-any.whl → 0.34.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 (100) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/__init__.py +18 -1
  3. tactus/adapters/broker_log.py +127 -34
  4. tactus/adapters/channels/__init__.py +153 -0
  5. tactus/adapters/channels/base.py +174 -0
  6. tactus/adapters/channels/broker.py +179 -0
  7. tactus/adapters/channels/cli.py +448 -0
  8. tactus/adapters/channels/host.py +225 -0
  9. tactus/adapters/channels/ipc.py +297 -0
  10. tactus/adapters/channels/sse.py +305 -0
  11. tactus/adapters/cli_hitl.py +223 -1
  12. tactus/adapters/control_loop.py +879 -0
  13. tactus/adapters/file_storage.py +35 -2
  14. tactus/adapters/ide_log.py +7 -1
  15. tactus/backends/http_backend.py +0 -1
  16. tactus/broker/client.py +31 -1
  17. tactus/broker/server.py +416 -92
  18. tactus/cli/app.py +270 -7
  19. tactus/cli/control.py +393 -0
  20. tactus/core/config_manager.py +33 -6
  21. tactus/core/dsl_stubs.py +102 -18
  22. tactus/core/execution_context.py +265 -8
  23. tactus/core/lua_sandbox.py +8 -9
  24. tactus/core/registry.py +19 -2
  25. tactus/core/runtime.py +235 -27
  26. tactus/docker/Dockerfile.pypi +49 -0
  27. tactus/docs/__init__.py +33 -0
  28. tactus/docs/extractor.py +326 -0
  29. tactus/docs/html_renderer.py +72 -0
  30. tactus/docs/models.py +121 -0
  31. tactus/docs/templates/base.html +204 -0
  32. tactus/docs/templates/index.html +58 -0
  33. tactus/docs/templates/module.html +96 -0
  34. tactus/dspy/agent.py +382 -22
  35. tactus/dspy/broker_lm.py +57 -6
  36. tactus/dspy/config.py +14 -3
  37. tactus/dspy/history.py +2 -1
  38. tactus/dspy/module.py +136 -11
  39. tactus/dspy/signature.py +0 -1
  40. tactus/ide/server.py +300 -9
  41. tactus/primitives/human.py +619 -47
  42. tactus/primitives/system.py +0 -1
  43. tactus/protocols/__init__.py +25 -0
  44. tactus/protocols/control.py +427 -0
  45. tactus/protocols/notification.py +207 -0
  46. tactus/sandbox/container_runner.py +79 -11
  47. tactus/sandbox/docker_manager.py +23 -0
  48. tactus/sandbox/entrypoint.py +26 -0
  49. tactus/sandbox/protocol.py +3 -0
  50. tactus/stdlib/README.md +77 -0
  51. tactus/stdlib/__init__.py +27 -1
  52. tactus/stdlib/classify/__init__.py +165 -0
  53. tactus/stdlib/classify/classify.spec.tac +195 -0
  54. tactus/stdlib/classify/classify.tac +257 -0
  55. tactus/stdlib/classify/fuzzy.py +282 -0
  56. tactus/stdlib/classify/llm.py +319 -0
  57. tactus/stdlib/classify/primitive.py +287 -0
  58. tactus/stdlib/core/__init__.py +57 -0
  59. tactus/stdlib/core/base.py +320 -0
  60. tactus/stdlib/core/confidence.py +211 -0
  61. tactus/stdlib/core/models.py +161 -0
  62. tactus/stdlib/core/retry.py +171 -0
  63. tactus/stdlib/core/validation.py +274 -0
  64. tactus/stdlib/extract/__init__.py +125 -0
  65. tactus/stdlib/extract/llm.py +330 -0
  66. tactus/stdlib/extract/primitive.py +256 -0
  67. tactus/stdlib/tac/tactus/classify/base.tac +51 -0
  68. tactus/stdlib/tac/tactus/classify/fuzzy.tac +87 -0
  69. tactus/stdlib/tac/tactus/classify/index.md +77 -0
  70. tactus/stdlib/tac/tactus/classify/init.tac +29 -0
  71. tactus/stdlib/tac/tactus/classify/llm.tac +150 -0
  72. tactus/stdlib/tac/tactus/classify.spec.tac +191 -0
  73. tactus/stdlib/tac/tactus/extract/base.tac +138 -0
  74. tactus/stdlib/tac/tactus/extract/index.md +96 -0
  75. tactus/stdlib/tac/tactus/extract/init.tac +27 -0
  76. tactus/stdlib/tac/tactus/extract/llm.tac +201 -0
  77. tactus/stdlib/tac/tactus/extract.spec.tac +153 -0
  78. tactus/stdlib/tac/tactus/generate/base.tac +142 -0
  79. tactus/stdlib/tac/tactus/generate/index.md +195 -0
  80. tactus/stdlib/tac/tactus/generate/init.tac +28 -0
  81. tactus/stdlib/tac/tactus/generate/llm.tac +169 -0
  82. tactus/stdlib/tac/tactus/generate.spec.tac +210 -0
  83. tactus/testing/behave_integration.py +171 -7
  84. tactus/testing/context.py +0 -1
  85. tactus/testing/evaluation_runner.py +0 -1
  86. tactus/testing/gherkin_parser.py +0 -1
  87. tactus/testing/mock_hitl.py +0 -1
  88. tactus/testing/mock_tools.py +0 -1
  89. tactus/testing/models.py +0 -1
  90. tactus/testing/steps/builtin.py +0 -1
  91. tactus/testing/steps/custom.py +81 -22
  92. tactus/testing/steps/registry.py +0 -1
  93. tactus/testing/test_runner.py +7 -1
  94. tactus/validation/semantic_visitor.py +11 -5
  95. tactus/validation/validator.py +0 -1
  96. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/METADATA +14 -2
  97. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/RECORD +100 -49
  98. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/WHEEL +0 -0
  99. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/entry_points.txt +0 -0
  100. {tactus-0.32.2.dist-info → tactus-0.34.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,179 @@
1
+ """
2
+ Broker Control Channel for container-to-host HITL requests.
3
+
4
+ Used inside Docker containers to forward control requests through the broker
5
+ to the host's SSE channel (or other host-side control channels).
6
+ """
7
+
8
+ import logging
9
+ import os
10
+ from datetime import datetime, timezone
11
+ from typing import Optional
12
+
13
+ from tactus.adapters.channels.base import InProcessChannel
14
+ from tactus.protocols.control import (
15
+ ChannelCapabilities,
16
+ ControlRequest,
17
+ ControlResponse,
18
+ DeliveryResult,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class BrokerControlChannel(InProcessChannel):
25
+ """
26
+ Control channel that forwards requests through broker to host.
27
+
28
+ Used when running inside a container with broker transport.
29
+ The broker server on the host relays to the actual control channels
30
+ (SSE, CLI, etc.) and returns responses.
31
+
32
+ Architecture:
33
+ - Container sends control.request via BrokerClient
34
+ - Host broker receives and forwards to SSE channel
35
+ - SSE channel delivers to IDE UI
36
+ - User responds in IDE
37
+ - Response flows back through broker to container
38
+ """
39
+
40
+ def __init__(self, client):
41
+ """
42
+ Initialize broker control channel.
43
+
44
+ Args:
45
+ client: BrokerClient instance for communication with host
46
+ """
47
+ super().__init__()
48
+ from tactus.broker.client import BrokerClient
49
+
50
+ if not isinstance(client, BrokerClient):
51
+ raise TypeError(f"Expected BrokerClient, got {type(client)}")
52
+
53
+ self._client = client
54
+
55
+ @property
56
+ def channel_id(self) -> str:
57
+ return "broker"
58
+
59
+ @property
60
+ def capabilities(self) -> ChannelCapabilities:
61
+ # Mirror SSE capabilities since broker relays to SSE
62
+ return ChannelCapabilities(
63
+ supports_approval=True,
64
+ supports_input=True,
65
+ supports_review=True,
66
+ supports_escalation=True,
67
+ supports_select=True,
68
+ supports_upload=True,
69
+ supports_inputs=True, # Batched inputs
70
+ supports_interactive_buttons=True,
71
+ is_synchronous=False, # Async relay through broker
72
+ )
73
+
74
+ async def initialize(self) -> None:
75
+ """Initialize broker control channel (broker already connected)."""
76
+ logger.info(f"{self.channel_id}: initializing...")
77
+ # Broker client already initialized by BrokerLogHandler setup
78
+ logger.info(f"{self.channel_id}: ready (via broker)")
79
+
80
+ async def send(self, request: ControlRequest) -> DeliveryResult:
81
+ """
82
+ Send control request through broker to host.
83
+
84
+ The request is serialized and sent via broker's control.request method.
85
+ The host will relay to its SSE channel and return the response.
86
+ """
87
+ logger.info(f"{self.channel_id}: sending control request {request.request_id} via broker")
88
+
89
+ try:
90
+ # Serialize request to JSON-compatible dict
91
+ request_data = request.model_dump(mode="json")
92
+
93
+ # Send via broker and wait for response events
94
+ async for event in self._client._request("control.request", {"request": request_data}):
95
+ event_type = event.get("event")
96
+
97
+ if event_type == "delivered":
98
+ # Request successfully delivered to host channels
99
+ logger.debug(f"{self.channel_id}: request {request.request_id} delivered")
100
+ continue
101
+
102
+ elif event_type == "response":
103
+ # Got response from host
104
+ response_data = event.get("data", {})
105
+ response = ControlResponse(
106
+ request_id=request.request_id,
107
+ value=response_data.get("value"),
108
+ responded_at=datetime.now(timezone.utc),
109
+ timed_out=response_data.get("timed_out", False),
110
+ channel_id=response_data.get("channel_id", "sse"),
111
+ responder_id=response_data.get("responder_id"),
112
+ )
113
+ logger.info(f"{self.channel_id}: received response for {request.request_id}")
114
+ self._response_queue.put_nowait(response)
115
+ break
116
+
117
+ elif event_type == "timeout":
118
+ # Host-side timeout
119
+ logger.warning(f"{self.channel_id}: timeout for {request.request_id}")
120
+ response = ControlResponse(
121
+ request_id=request.request_id,
122
+ value=request.default_value,
123
+ responded_at=datetime.now(timezone.utc),
124
+ timed_out=True,
125
+ channel_id="broker",
126
+ )
127
+ self._response_queue.put_nowait(response)
128
+ break
129
+
130
+ elif event_type == "error":
131
+ # Delivery or processing error
132
+ error = event.get("error", {})
133
+ error_msg = error.get("message", "Unknown broker error")
134
+ logger.error(f"{self.channel_id}: error for {request.request_id}: {error_msg}")
135
+ raise RuntimeError(f"Broker control request failed: {error_msg}")
136
+
137
+ return DeliveryResult(
138
+ channel_id=self.channel_id,
139
+ external_message_id=request.request_id,
140
+ delivered_at=datetime.now(timezone.utc),
141
+ success=True,
142
+ )
143
+
144
+ except Exception as e:
145
+ logger.error(f"{self.channel_id}: failed to send {request.request_id}: {e}")
146
+ return DeliveryResult(
147
+ channel_id=self.channel_id,
148
+ external_message_id=request.request_id,
149
+ delivered_at=datetime.now(timezone.utc),
150
+ success=False,
151
+ error_message=str(e),
152
+ )
153
+
154
+ @classmethod
155
+ def from_environment(cls) -> Optional["BrokerControlChannel"]:
156
+ """
157
+ Create BrokerControlChannel from environment if broker is available.
158
+
159
+ Checks for TACTUS_BROKER_SOCKET environment variable and creates
160
+ a broker client if found.
161
+
162
+ Returns:
163
+ BrokerControlChannel instance if broker available, None otherwise
164
+ """
165
+ socket_path = os.environ.get("TACTUS_BROKER_SOCKET")
166
+ if not socket_path:
167
+ return None
168
+
169
+ try:
170
+ from tactus.broker.client import BrokerClient
171
+
172
+ client = BrokerClient(socket_path)
173
+ logger.info(
174
+ f"BrokerControlChannel: initialized from environment (socket={socket_path})"
175
+ )
176
+ return cls(client)
177
+ except Exception as e:
178
+ logger.warning(f"BrokerControlChannel: failed to initialize from environment: {e}")
179
+ return None
@@ -0,0 +1,448 @@
1
+ """
2
+ CLI control channel implementation.
3
+
4
+ Provides interactive command-line prompts for control loop interactions.
5
+ Uses Rich for formatting and the host channel pattern for interruptibility.
6
+ """
7
+
8
+ import sys
9
+ import logging
10
+ from typing import Optional, Any
11
+ from datetime import datetime, timezone
12
+
13
+ from rich.console import Console
14
+ from rich.prompt import Prompt, Confirm
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+
18
+ from tactus.protocols.control import (
19
+ ControlRequest,
20
+ ControlRequestType,
21
+ ControlOption,
22
+ ChannelCapabilities,
23
+ )
24
+ from tactus.adapters.channels.host import HostControlChannel
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def format_time_ago(dt: datetime) -> str:
30
+ """Format datetime as human-readable time ago string."""
31
+ now = datetime.now(timezone.utc)
32
+ delta = now - dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else now - dt
33
+
34
+ seconds = int(delta.total_seconds())
35
+ if seconds < 60:
36
+ return f"{seconds} seconds"
37
+ minutes = seconds // 60
38
+ if minutes < 60:
39
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
40
+ hours = minutes // 60
41
+ if hours < 24:
42
+ return f"{hours} hour{'s' if hours != 1 else ''}"
43
+ days = hours // 24
44
+ return f"{days} day{'s' if days != 1 else ''}"
45
+
46
+
47
+ class CLIControlChannel(HostControlChannel):
48
+ """
49
+ CLI-based control channel using Rich prompts.
50
+
51
+ Provides interactive command-line prompts for approval, input,
52
+ review, and escalation requests. Can be interrupted if another
53
+ channel responds first.
54
+
55
+ Example:
56
+ channel = CLIControlChannel()
57
+ await channel.initialize()
58
+ result = await channel.send(request)
59
+ # ... wait for response via receive() or cancellation
60
+ """
61
+
62
+ def __init__(self, console: Optional[Console] = None):
63
+ """
64
+ Initialize CLI control channel.
65
+
66
+ Args:
67
+ console: Rich Console instance (creates new one if not provided)
68
+ """
69
+ super().__init__()
70
+ self.console = console or Console()
71
+
72
+ @property
73
+ def channel_id(self) -> str:
74
+ """Return channel identifier."""
75
+ return "cli"
76
+
77
+ @property
78
+ def capabilities(self) -> ChannelCapabilities:
79
+ """CLI supports all request types with immediate responses."""
80
+ return ChannelCapabilities(
81
+ supports_approval=True,
82
+ supports_input=True,
83
+ supports_review=True,
84
+ supports_escalation=True,
85
+ supports_interactive_buttons=False,
86
+ supports_file_attachments=False,
87
+ max_message_length=None,
88
+ is_synchronous=True,
89
+ )
90
+
91
+ async def initialize(self) -> None:
92
+ """Initialize the CLI channel."""
93
+ logger.info(f"{self.channel_id}: initializing...")
94
+ # Check if stdin is a tty
95
+ if not sys.stdin.isatty():
96
+ logger.warning(f"{self.channel_id}: stdin is not a tty, prompts may not work")
97
+ logger.info(f"{self.channel_id}: ready")
98
+
99
+ def _display_request(self, request: ControlRequest) -> None:
100
+ """
101
+ Display the control request with rich formatting.
102
+
103
+ Shows:
104
+ - Procedure name and subject
105
+ - Elapsed time
106
+ - Input summary (if provided)
107
+ - Prior interactions (if any)
108
+ - The message and options
109
+
110
+ Args:
111
+ request: The control request to display
112
+ """
113
+ self.console.print()
114
+
115
+ # Header: procedure name and subject
116
+ header = f"[bold]{request.procedure_name}[/bold]"
117
+ if request.subject:
118
+ header += f": [cyan]{request.subject}[/cyan]"
119
+ self.console.print(header)
120
+
121
+ # Timing
122
+ self.console.print(f"[dim]Started {format_time_ago(request.started_at)} ago[/dim]")
123
+
124
+ # Input summary
125
+ if request.input_summary:
126
+ table = Table(title="Input Data", show_header=False, box=None)
127
+ for key, value in request.input_summary.items():
128
+ table.add_row(f"[dim]{key}:[/dim]", str(value))
129
+ self.console.print(Panel(table, border_style="dim"))
130
+
131
+ # Prior interactions
132
+ if request.prior_interactions:
133
+ self.console.print("\n[dim]Previous decisions:[/dim]")
134
+ for interaction in request.prior_interactions:
135
+ responder = interaction.responded_by or interaction.channel_id
136
+ self.console.print(f" [dim]•[/dim] {responder}: {interaction.response_value}")
137
+
138
+ # The message
139
+ self.console.print()
140
+ self.console.print(
141
+ Panel(
142
+ request.message,
143
+ title=f"[bold]{request.request_type.value.upper()}[/bold]",
144
+ style="yellow",
145
+ )
146
+ )
147
+
148
+ def _prompt_for_input(self, request: ControlRequest) -> Optional[Any]:
149
+ """
150
+ Collect input from the user via CLI prompt.
151
+
152
+ Routes to appropriate handler based on request type.
153
+
154
+ Args:
155
+ request: The control request being handled
156
+
157
+ Returns:
158
+ The user's response value, or None if cancelled
159
+ """
160
+ if self.is_cancelled():
161
+ return None
162
+
163
+ request_type = request.request_type
164
+ if request_type == ControlRequestType.APPROVAL:
165
+ return self._handle_approval(request)
166
+ elif request_type == ControlRequestType.INPUT:
167
+ return self._handle_input(request)
168
+ elif request_type == ControlRequestType.REVIEW:
169
+ return self._handle_review(request)
170
+ elif request_type == ControlRequestType.ESCALATION:
171
+ return self._handle_escalation(request)
172
+ elif request_type == ControlRequestType.INPUTS:
173
+ return self._handle_inputs(request)
174
+ else:
175
+ # Default: treat as input
176
+ return self._handle_input(request)
177
+
178
+ def _handle_approval(self, request: ControlRequest) -> Optional[bool]:
179
+ """Handle approval request."""
180
+ if self.is_cancelled():
181
+ return None
182
+
183
+ default = request.default_value if request.default_value is not None else False
184
+
185
+ try:
186
+ approved = Confirm.ask("Approve?", default=default, console=self.console)
187
+ return approved if not self.is_cancelled() else None
188
+ except (EOFError, KeyboardInterrupt):
189
+ return None
190
+
191
+ def _handle_input(self, request: ControlRequest) -> Optional[Any]:
192
+ """Handle input request."""
193
+ if self.is_cancelled():
194
+ return None
195
+
196
+ default = str(request.default_value) if request.default_value is not None else None
197
+
198
+ try:
199
+ # Check if there are options
200
+ if request.options:
201
+ return self._handle_options(request.options, default)
202
+ else:
203
+ # Free-form input
204
+ value = Prompt.ask("Enter value", default=default, console=self.console)
205
+ return value if not self.is_cancelled() else None
206
+ except (EOFError, KeyboardInterrupt):
207
+ return None
208
+
209
+ def _handle_options(
210
+ self, options: list[ControlOption], default: Optional[str]
211
+ ) -> Optional[Any]:
212
+ """Handle options selection."""
213
+ # Display options
214
+ self.console.print("\n[bold]Options:[/bold]")
215
+ for i, option in enumerate(options, 1):
216
+ self.console.print(f" {i}. [cyan]{option.label}[/cyan]")
217
+ if option.description:
218
+ self.console.print(f" [dim]{option.description}[/dim]")
219
+
220
+ # Get choice
221
+ while not self.is_cancelled():
222
+ try:
223
+ choice_str = Prompt.ask(
224
+ "Select option (number)",
225
+ default=default,
226
+ console=self.console,
227
+ )
228
+
229
+ try:
230
+ choice = int(choice_str)
231
+ if 1 <= choice <= len(options):
232
+ return options[choice - 1].value
233
+ else:
234
+ self.console.print(f"[red]Invalid choice. Enter 1-{len(options)}[/red]")
235
+ except ValueError:
236
+ self.console.print("[red]Invalid input. Enter a number[/red]")
237
+ except (EOFError, KeyboardInterrupt):
238
+ return None
239
+
240
+ return None
241
+
242
+ def _handle_review(self, request: ControlRequest) -> Optional[dict]:
243
+ """Handle review request."""
244
+ if self.is_cancelled():
245
+ return None
246
+
247
+ self.console.print("\n[bold]Review Options:[/bold]")
248
+ self.console.print(" 1. [green]Approve[/green] - Accept as-is")
249
+ self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
250
+ self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
251
+
252
+ while not self.is_cancelled():
253
+ try:
254
+ choice = Prompt.ask(
255
+ "Your decision",
256
+ choices=["1", "2", "3", "approve", "edit", "reject"],
257
+ default="1",
258
+ console=self.console,
259
+ )
260
+
261
+ if self.is_cancelled():
262
+ return None
263
+
264
+ if choice in ["1", "approve"]:
265
+ return {"decision": "approved", "feedback": None, "edited_artifact": None}
266
+ elif choice in ["2", "edit"]:
267
+ feedback = Prompt.ask("What changes would you like?", console=self.console)
268
+ if self.is_cancelled():
269
+ return None
270
+ return {"decision": "approved", "feedback": feedback, "edited_artifact": None}
271
+ elif choice in ["3", "reject"]:
272
+ feedback = Prompt.ask("Why are you rejecting?", console=self.console)
273
+ if self.is_cancelled():
274
+ return None
275
+ return {"decision": "rejected", "feedback": feedback, "edited_artifact": None}
276
+ except (EOFError, KeyboardInterrupt):
277
+ return None
278
+
279
+ return None
280
+
281
+ def _handle_escalation(self, request: ControlRequest) -> Optional[None]:
282
+ """Handle escalation request (acknowledgment only)."""
283
+ if self.is_cancelled():
284
+ return None
285
+
286
+ self.console.print("\n[yellow bold]⚠ This issue requires escalation[/yellow bold]")
287
+
288
+ try:
289
+ Confirm.ask(
290
+ "Press Enter to acknowledge and continue",
291
+ default=True,
292
+ show_default=False,
293
+ console=self.console,
294
+ )
295
+ return None if self.is_cancelled() else True
296
+ except (EOFError, KeyboardInterrupt):
297
+ return None
298
+
299
+ def _handle_inputs(self, request: ControlRequest) -> Optional[dict]:
300
+ """Handle batched inputs request (multiple inputs in one interaction)."""
301
+ if self.is_cancelled():
302
+ return None
303
+
304
+ items = request.items or []
305
+
306
+ if not items:
307
+ self.console.print("[red]Error: No items found in inputs request[/red]")
308
+ return {}
309
+
310
+ # Display summary
311
+ self.console.print(f"\n[bold cyan]Collecting {len(items)} inputs:[/bold cyan]")
312
+ for idx, item in enumerate(items, 1):
313
+ req_marker = "*" if item.required else ""
314
+ self.console.print(f" {idx}. [cyan]{item.label}[/cyan]{req_marker}")
315
+ self.console.print()
316
+
317
+ # Collect responses for each item
318
+ responses = {}
319
+
320
+ for idx, item in enumerate(items, 1):
321
+ if self.is_cancelled():
322
+ return None
323
+
324
+ # Display item panel
325
+ self.console.print(
326
+ Panel(
327
+ item.message,
328
+ title=f"[bold]{idx}/{len(items)}: {item.label}[/bold]",
329
+ style="cyan" if item.required else "blue",
330
+ )
331
+ )
332
+
333
+ # Handle based on item type
334
+ try:
335
+ value = None
336
+ if item.request_type == ControlRequestType.APPROVAL:
337
+ default = item.default_value if item.default_value is not None else False
338
+ value = Confirm.ask("Approve?", default=default, console=self.console)
339
+
340
+ elif item.request_type == ControlRequestType.INPUT:
341
+ placeholder = item.metadata.get("placeholder", "") if item.metadata else ""
342
+ multiline = item.metadata.get("multiline", False) if item.metadata else False
343
+
344
+ if multiline:
345
+ self.console.print("[dim](Enter text, press Ctrl+D when done)[/dim]")
346
+ lines = []
347
+ try:
348
+ while not self.is_cancelled():
349
+ line = Prompt.ask("", console=self.console, show_default=False)
350
+ lines.append(line)
351
+ except EOFError:
352
+ value = "\n".join(lines)
353
+ else:
354
+ prompt_text = "Enter value"
355
+ if placeholder:
356
+ prompt_text = f"{prompt_text} ({placeholder})"
357
+ default_str = (
358
+ str(item.default_value) if item.default_value is not None else None
359
+ )
360
+ value = Prompt.ask(prompt_text, default=default_str, console=self.console)
361
+
362
+ elif item.request_type == ControlRequestType.SELECT:
363
+ # For now, use the simple options handler
364
+ # This could be enhanced to support metadata.mode = "multiple"
365
+ value = self._handle_options(item.options, item.default_value)
366
+
367
+ elif item.request_type == ControlRequestType.REVIEW:
368
+ self.console.print("\n[bold]Review Options:[/bold]")
369
+ self.console.print(" 1. [green]Approve[/green] - Accept as-is")
370
+ self.console.print(" 2. [yellow]Edit[/yellow] - Provide changes")
371
+ self.console.print(" 3. [red]Reject[/red] - Reject and request redo")
372
+
373
+ choice = Prompt.ask(
374
+ "Your decision",
375
+ choices=["1", "2", "3", "approve", "edit", "reject"],
376
+ default="1",
377
+ console=self.console,
378
+ )
379
+
380
+ if choice in ["1", "approve"]:
381
+ decision = "approved"
382
+ feedback = None
383
+ elif choice in ["2", "edit"]:
384
+ decision = "approved"
385
+ feedback = Prompt.ask("What changes would you like?", console=self.console)
386
+ else: # reject
387
+ decision = "rejected"
388
+ feedback = Prompt.ask("Why are you rejecting?", console=self.console)
389
+
390
+ value = {"decision": decision, "feedback": feedback}
391
+
392
+ else:
393
+ # Default to input
394
+ value = Prompt.ask("Enter value", console=self.console)
395
+
396
+ # Store response if required or if value was provided
397
+ if self.is_cancelled():
398
+ return None
399
+
400
+ if item.required or value:
401
+ responses[item.item_id] = value
402
+
403
+ self.console.print() # Add spacing
404
+
405
+ except (EOFError, KeyboardInterrupt):
406
+ return None
407
+
408
+ # Display summary
409
+ if not self.is_cancelled():
410
+ self.console.print("[bold green]✓ All inputs collected[/bold green]")
411
+ self.console.print("\n[bold]Summary:[/bold]")
412
+ for item_id, value in responses.items():
413
+ item_label = next(
414
+ (item.label for item in items if item.item_id == item_id), item_id
415
+ )
416
+ value_str = (
417
+ str(value) if not isinstance(value, list) else ", ".join(str(v) for v in value)
418
+ )
419
+ if len(value_str) > 60:
420
+ value_str = value_str[:57] + "..."
421
+ self.console.print(f" [cyan]{item_label}:[/cyan] {value_str}")
422
+
423
+ return None if self.is_cancelled() else responses
424
+
425
+ def _show_cancelled(self, reason: str) -> None:
426
+ """
427
+ Show cancellation message.
428
+
429
+ Displays a green checkmark and the reason (typically
430
+ "Responded via {channel}").
431
+
432
+ Args:
433
+ reason: Reason for cancellation
434
+ """
435
+ self.console.print(f"\n[green]✓ {reason}[/green]")
436
+
437
+
438
+ def is_cli_available() -> bool:
439
+ """
440
+ Check if CLI control channel is available.
441
+
442
+ Returns True if stdin is a tty (interactive terminal).
443
+ Used for auto-detection of whether to enable CLI channel.
444
+
445
+ Returns:
446
+ True if CLI is available, False otherwise
447
+ """
448
+ return sys.stdin.isatty()