functualize-inline 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.
@@ -0,0 +1,5 @@
1
+ """Functualize Inline Plugin - Textual inline interactivity for terminal prompts."""
2
+
3
+ from functualize_inline.plugin import InlinePlugin
4
+
5
+ __all__ = ["InlinePlugin"]
@@ -0,0 +1,74 @@
1
+ """Textual inline applications for prompt rendering.
2
+
3
+ Uses Textual's `inline=True` mode to render widgets inline within the terminal
4
+ without taking over the full screen. Each prompt spawns a short-lived inline app
5
+ that returns the user's response.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from textual import on
13
+ from textual.app import App, ComposeResult
14
+
15
+ from functualize_inline.widgets import (
16
+ PromptResult,
17
+ )
18
+
19
+
20
+ class InlinePromptApp(App[tuple[Any, str]]):
21
+ """A short-lived Textual app that renders a prompt widget inline.
22
+
23
+ Returns a tuple of (value, source) when the user interacts with the widget.
24
+ The app runs in inline mode and exits once a PromptResult message is received.
25
+ """
26
+
27
+ CSS = """
28
+ Screen {
29
+ layout: vertical;
30
+ overflow-y: auto;
31
+ }
32
+ """
33
+
34
+ BINDINGS = [
35
+ ("ctrl+c", "force_cancel", "Cancel"),
36
+ ]
37
+
38
+ def __init__(
39
+ self,
40
+ widget_class: type,
41
+ widget_kwargs: dict[str, Any],
42
+ timeout: float | None = None,
43
+ **kwargs: Any,
44
+ ) -> None:
45
+ super().__init__(inline=True, **kwargs)
46
+ self._widget_class = widget_class
47
+ self._widget_kwargs = widget_kwargs
48
+ self._timeout = timeout
49
+ self._timeout_timer: Any = None
50
+
51
+ def compose(self) -> ComposeResult:
52
+ yield self._widget_class(**self._widget_kwargs)
53
+
54
+ def on_mount(self) -> None:
55
+ """Start timeout timer if configured."""
56
+ if self._timeout is not None and self._timeout > 0:
57
+ self._timeout_timer = self.set_timer(self._timeout, self._on_timeout)
58
+
59
+ def _on_timeout(self) -> None:
60
+ """Auto-dismiss on timeout."""
61
+ self.exit((None, "timeout"))
62
+
63
+ @on(PromptResult)
64
+ def _on_prompt_result(self, message: PromptResult) -> None:
65
+ """Handle result from the prompt widget."""
66
+ if self._timeout_timer is not None:
67
+ self._timeout_timer.stop()
68
+ self.exit((message.value, message.source))
69
+
70
+ def action_force_cancel(self) -> None:
71
+ """Handle Ctrl+C."""
72
+ if self._timeout_timer is not None:
73
+ self._timeout_timer.stop()
74
+ self.exit((None, "cancelled"))
@@ -0,0 +1,335 @@
1
+ """Functualize Inline Plugin — a PromptCollector using Textual inline mode.
2
+
3
+ Implements ``collect`` to render rich inline terminal widgets for each
4
+ PromptIntent. Falls back to plain CLI input() when Textual inline mode is
5
+ unavailable (non-TTY, import failure, or lacking terminal support).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import sys
12
+ from typing import Any
13
+
14
+ from functualize._types.interactivity import (
15
+ PromptIntent,
16
+ PromptRequest,
17
+ PromptResponse,
18
+ )
19
+
20
+ __all__ = ["InlinePlugin"]
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ def _is_inline_available() -> bool:
26
+ """Check if Textual inline mode is available.
27
+
28
+ Returns False if:
29
+ - stdin/stdout is not a TTY
30
+ - Textual cannot be imported
31
+ - Terminal lacks inline support
32
+ """
33
+ try:
34
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
35
+ return False
36
+ # Verify Textual can be imported and inline mode is supported
37
+ from textual.app import App # noqa: F401
38
+
39
+ return True
40
+ except (ImportError, AttributeError, OSError):
41
+ return False
42
+
43
+
44
+ class InlinePlugin:
45
+ """Textual inline PromptCollector for functualize prompts.
46
+
47
+ Dispatches PromptRequest objects to appropriate Textual inline widgets
48
+ based on PromptIntent. Falls back to plain CLI when Textual inline is
49
+ unavailable.
50
+ """
51
+
52
+ name: str = "inline"
53
+ version: str = "0.1.0"
54
+ description: str = "Textual inline terminal prompts"
55
+
56
+ def __call__(self, app: Any) -> None:
57
+ """Register this plugin as a PromptCollector with the application."""
58
+ try:
59
+ app.register_surface(self)
60
+ logger.debug("InlinePlugin registered as PromptCollector")
61
+ except Exception as e:
62
+ logger.warning("InlinePlugin: Failed to register: %s", e)
63
+
64
+ # ─── PromptCollector Protocol ────────────────────────────────────
65
+
66
+ def collect(self, request: PromptRequest) -> PromptResponse:
67
+ """Collect user input for the given prompt request.
68
+
69
+ Dispatches to Textual inline widgets when available, otherwise
70
+ falls back to plain CLI input().
71
+
72
+ Args:
73
+ request: The structured prompt request with intent, choices, etc.
74
+
75
+ Returns:
76
+ PromptResponse with the user's value and source metadata.
77
+ """
78
+ if _is_inline_available():
79
+ return self._prompt_inline(request)
80
+ return self._prompt_cli_fallback(request)
81
+
82
+ # ─── Textual Inline Path ─────────────────────────────────────────
83
+
84
+ def _prompt_inline(self, request: PromptRequest) -> PromptResponse:
85
+ """Render a Textual inline widget based on prompt intent."""
86
+ from functualize_inline.apps import InlinePromptApp
87
+ from functualize_inline.widgets import (
88
+ AcknowledgeWidget,
89
+ ConfirmDestructiveWidget,
90
+ ConfirmNeutralWidget,
91
+ MultiSelectWidget,
92
+ SecretInputWidget,
93
+ SelectWidget,
94
+ TextInputWidget,
95
+ )
96
+
97
+ intent = request.intent
98
+ widget_class: type
99
+ widget_kwargs: dict[str, Any] = {}
100
+
101
+ if intent == PromptIntent.CONFIRM_DESTRUCTIVE:
102
+ widget_class = ConfirmDestructiveWidget
103
+ widget_kwargs = {
104
+ "question": request.question,
105
+ "context_message": request.context_message,
106
+ }
107
+ elif intent in (PromptIntent.CONFIRM_NEUTRAL, PromptIntent.CONFIRM_PROCEED):
108
+ widget_class = ConfirmNeutralWidget
109
+ widget_kwargs = {
110
+ "question": request.question,
111
+ "default": request.default,
112
+ "context_message": request.context_message,
113
+ }
114
+ elif intent == PromptIntent.SELECT:
115
+ widget_class = SelectWidget
116
+ widget_kwargs = {
117
+ "question": request.question,
118
+ "choices": request.choices or [],
119
+ "context_message": request.context_message,
120
+ }
121
+ elif intent == PromptIntent.MULTI_SELECT:
122
+ widget_class = MultiSelectWidget
123
+ widget_kwargs = {
124
+ "question": request.question,
125
+ "choices": request.choices or [],
126
+ "context_message": request.context_message,
127
+ }
128
+ elif intent == PromptIntent.SECRET_INPUT:
129
+ widget_class = SecretInputWidget
130
+ widget_kwargs = {
131
+ "question": request.question,
132
+ "placeholder": request.placeholder,
133
+ "context_message": request.context_message,
134
+ }
135
+ elif intent == PromptIntent.ACKNOWLEDGE:
136
+ widget_class = AcknowledgeWidget
137
+ widget_kwargs = {
138
+ "question": request.question,
139
+ "context_message": request.context_message,
140
+ }
141
+ else:
142
+ # TEXT_INPUT or any unknown intent
143
+ widget_class = TextInputWidget
144
+ widget_kwargs = {
145
+ "question": request.question,
146
+ "placeholder": request.placeholder,
147
+ "default": request.default,
148
+ "context_message": request.context_message,
149
+ }
150
+
151
+ try:
152
+ app = InlinePromptApp(
153
+ widget_class=widget_class,
154
+ widget_kwargs=widget_kwargs,
155
+ timeout=request.timeout,
156
+ )
157
+ result = app.run()
158
+
159
+ if result is None:
160
+ # App exited without returning a result (shouldn't happen normally)
161
+ return PromptResponse(value=None, source="cancelled")
162
+
163
+ value, source = result
164
+
165
+ # For timeout, use the default value
166
+ if source == "timeout":
167
+ return PromptResponse(value=request.default, source="timeout")
168
+
169
+ return PromptResponse(value=value, source=source)
170
+
171
+ except Exception as e:
172
+ logger.warning(
173
+ "InlinePlugin: Textual inline failed (%s), falling back to CLI", e
174
+ )
175
+ return self._prompt_cli_fallback(request)
176
+
177
+ # ─── Plain CLI Fallback ──────────────────────────────────────────
178
+
179
+ def _prompt_cli_fallback(self, request: PromptRequest) -> PromptResponse:
180
+ """Plain CLI fallback using standard input/print.
181
+
182
+ Used when Textual inline mode is unavailable.
183
+ """
184
+ intent = request.intent
185
+
186
+ try:
187
+ if intent == PromptIntent.CONFIRM_DESTRUCTIVE:
188
+ return self._cli_confirm_destructive(request)
189
+ elif intent in (PromptIntent.CONFIRM_NEUTRAL, PromptIntent.CONFIRM_PROCEED):
190
+ return self._cli_confirm_neutral(request)
191
+ elif intent == PromptIntent.SELECT:
192
+ return self._cli_select(request)
193
+ elif intent == PromptIntent.MULTI_SELECT:
194
+ return self._cli_multi_select(request)
195
+ elif intent == PromptIntent.SECRET_INPUT:
196
+ return self._cli_secret_input(request)
197
+ elif intent == PromptIntent.ACKNOWLEDGE:
198
+ return self._cli_acknowledge(request)
199
+ else:
200
+ return self._cli_text_input(request)
201
+ except (KeyboardInterrupt, EOFError):
202
+ return PromptResponse(value=None, source="cancelled")
203
+
204
+ def _cli_confirm_destructive(self, request: PromptRequest) -> PromptResponse:
205
+ """CLI fallback for CONFIRM_DESTRUCTIVE: requires typing 'yes'."""
206
+ if request.context_message:
207
+ print(f" {request.context_message}")
208
+ prompt_text = f"⚠ {request.question} (type 'yes' to confirm): "
209
+ try:
210
+ response = input(prompt_text)
211
+ except (KeyboardInterrupt, EOFError):
212
+ return PromptResponse(value=None, source="cancelled")
213
+ confirmed = response.strip().lower() == "yes"
214
+ return PromptResponse(value=confirmed, source="user")
215
+
216
+ def _cli_confirm_neutral(self, request: PromptRequest) -> PromptResponse:
217
+ """CLI fallback for CONFIRM_NEUTRAL: Y/n prompt."""
218
+ if request.context_message:
219
+ print(f" {request.context_message}")
220
+ default_hint = "[Y/n]" if request.default is True else "[y/N]"
221
+ prompt_text = f"{request.question} {default_hint}: "
222
+ try:
223
+ response = input(prompt_text)
224
+ except (KeyboardInterrupt, EOFError):
225
+ return PromptResponse(value=None, source="cancelled")
226
+ val = response.strip().lower()
227
+ if val == "":
228
+ result = request.default if request.default is not None else True
229
+ elif val in ("y", "yes"):
230
+ result = True
231
+ else:
232
+ result = False
233
+ return PromptResponse(value=result, source="user")
234
+
235
+ def _cli_select(self, request: PromptRequest) -> PromptResponse:
236
+ """CLI fallback for SELECT: numbered list."""
237
+ if request.context_message:
238
+ print(f" {request.context_message}")
239
+ print(f"{request.question}")
240
+ choices = request.choices or []
241
+ for i, choice in enumerate(choices, 1):
242
+ label = choice.label or choice.value
243
+ disabled = " [disabled]" if choice.disabled else ""
244
+ desc = f" — {choice.description}" if choice.description else ""
245
+ print(f" {i}. {label}{desc}{disabled}")
246
+
247
+ prompt_text = "Select (number): "
248
+ try:
249
+ response = input(prompt_text)
250
+ except (KeyboardInterrupt, EOFError):
251
+ return PromptResponse(value=None, source="cancelled")
252
+
253
+ try:
254
+ idx = int(response.strip()) - 1
255
+ if 0 <= idx < len(choices):
256
+ selected = choices[idx]
257
+ if selected.disabled:
258
+ print(" That option is disabled.")
259
+ return PromptResponse(value=request.default, source="user")
260
+ return PromptResponse(value=selected.value, source="user")
261
+ except (ValueError, IndexError):
262
+ pass
263
+
264
+ # Invalid selection — return default if available
265
+ if request.default is not None:
266
+ return PromptResponse(value=request.default, source="default")
267
+ return PromptResponse(value=None, source="cancelled")
268
+
269
+ def _cli_multi_select(self, request: PromptRequest) -> PromptResponse:
270
+ """CLI fallback for MULTI_SELECT: numbered list with comma-separated input."""
271
+ if request.context_message:
272
+ print(f" {request.context_message}")
273
+ print(f"{request.question}")
274
+ choices = request.choices or []
275
+ for i, choice in enumerate(choices, 1):
276
+ label = choice.label or choice.value
277
+ disabled = " [disabled]" if choice.disabled else ""
278
+ desc = f" — {choice.description}" if choice.description else ""
279
+ print(f" {i}. {label}{desc}{disabled}")
280
+
281
+ prompt_text = "Select (comma-separated numbers): "
282
+ try:
283
+ response = input(prompt_text)
284
+ except (KeyboardInterrupt, EOFError):
285
+ return PromptResponse(value=None, source="cancelled")
286
+
287
+ selected: list[str] = []
288
+ for part in response.split(","):
289
+ part = part.strip()
290
+ try:
291
+ idx = int(part) - 1
292
+ if 0 <= idx < len(choices) and not choices[idx].disabled:
293
+ selected.append(choices[idx].value)
294
+ except (ValueError, IndexError):
295
+ continue
296
+
297
+ return PromptResponse(value=selected, source="user")
298
+
299
+ def _cli_secret_input(self, request: PromptRequest) -> PromptResponse:
300
+ """CLI fallback for SECRET_INPUT: uses getpass for masked input."""
301
+ import getpass
302
+
303
+ if request.context_message:
304
+ print(f" {request.context_message}")
305
+ prompt_text = f"{request.question}: "
306
+ try:
307
+ value = getpass.getpass(prompt_text)
308
+ except (KeyboardInterrupt, EOFError):
309
+ return PromptResponse(value=None, source="cancelled")
310
+ return PromptResponse(value=value, source="user")
311
+
312
+ def _cli_acknowledge(self, request: PromptRequest) -> PromptResponse:
313
+ """CLI fallback for ACKNOWLEDGE: press Enter to continue."""
314
+ if request.context_message:
315
+ print(f" {request.context_message}")
316
+ prompt_text = f"{request.question} [Press Enter to continue]: "
317
+ try:
318
+ input(prompt_text)
319
+ except (KeyboardInterrupt, EOFError):
320
+ return PromptResponse(value=None, source="cancelled")
321
+ return PromptResponse(value=True, source="user")
322
+
323
+ def _cli_text_input(self, request: PromptRequest) -> PromptResponse:
324
+ """CLI fallback for TEXT_INPUT: plain input()."""
325
+ if request.context_message:
326
+ print(f" {request.context_message}")
327
+ default_hint = f" [{request.default}]" if request.default is not None else ""
328
+ prompt_text = f"{request.question}{default_hint}: "
329
+ try:
330
+ value = input(prompt_text)
331
+ except (KeyboardInterrupt, EOFError):
332
+ return PromptResponse(value=None, source="cancelled")
333
+ if not value and request.default is not None:
334
+ return PromptResponse(value=request.default, source="default")
335
+ return PromptResponse(value=value, source="user")
File without changes
@@ -0,0 +1,437 @@
1
+ """Custom Textual widgets for inline prompt rendering.
2
+
3
+ Each widget corresponds to a PromptIntent and handles user interaction,
4
+ returning the result via a message posted to the parent app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from textual import on
12
+ from textual.binding import Binding
13
+ from textual.message import Message
14
+ from textual.widget import Widget
15
+ from textual.widgets import Input, Label, OptionList, Static
16
+ from textual.widgets.option_list import Option
17
+
18
+ if TYPE_CHECKING:
19
+ from textual.app import ComposeResult
20
+
21
+
22
+ class PromptResult(Message):
23
+ """Message posted when a widget produces a result."""
24
+
25
+ def __init__(self, value: object, source: str = "user") -> None:
26
+ super().__init__()
27
+ self.value = value
28
+ self.source = source
29
+
30
+
31
+ class ConfirmDestructiveWidget(Widget):
32
+ """Red-bordered widget requiring the user to type 'yes' to confirm."""
33
+
34
+ BINDINGS = [
35
+ Binding("escape", "cancel", "Cancel", show=False),
36
+ ]
37
+
38
+ DEFAULT_CSS = """
39
+ ConfirmDestructiveWidget {
40
+ border: heavy red;
41
+ padding: 1 2;
42
+ height: auto;
43
+ width: 100%;
44
+ }
45
+ ConfirmDestructiveWidget Label {
46
+ margin-bottom: 1;
47
+ }
48
+ ConfirmDestructiveWidget .context-msg {
49
+ color: $text-muted;
50
+ margin-bottom: 1;
51
+ }
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ question: str,
57
+ context_message: str | None = None,
58
+ **kwargs: object,
59
+ ) -> None:
60
+ super().__init__(**kwargs)
61
+ self._question = question
62
+ self._context_message = context_message
63
+
64
+ def compose(self) -> ComposeResult:
65
+ yield Label(f"[bold red]⚠ {self._question}[/]")
66
+ if self._context_message:
67
+ yield Static(self._context_message, classes="context-msg")
68
+ yield Label('[dim]Type "yes" to confirm, anything else to cancel[/]')
69
+ yield Input(placeholder="yes")
70
+
71
+ @on(Input.Submitted)
72
+ def _on_submit(self, event: Input.Submitted) -> None:
73
+ confirmed = event.value.strip().lower() == "yes"
74
+ self.post_message(PromptResult(value=confirmed, source="user"))
75
+
76
+ def action_cancel(self) -> None:
77
+ self.post_message(PromptResult(value=None, source="cancelled"))
78
+
79
+
80
+ class ConfirmNeutralWidget(Widget):
81
+ """Standard Y/n confirmation widget."""
82
+
83
+ BINDINGS = [
84
+ Binding("escape", "cancel", "Cancel", show=False),
85
+ Binding("y", "confirm_yes", "Yes", show=False),
86
+ Binding("n", "confirm_no", "No", show=False),
87
+ ]
88
+
89
+ DEFAULT_CSS = """
90
+ ConfirmNeutralWidget {
91
+ border: solid $accent;
92
+ padding: 1 2;
93
+ height: auto;
94
+ width: 100%;
95
+ }
96
+ ConfirmNeutralWidget Label {
97
+ margin-bottom: 1;
98
+ }
99
+ ConfirmNeutralWidget .context-msg {
100
+ color: $text-muted;
101
+ margin-bottom: 1;
102
+ }
103
+ """
104
+
105
+ def __init__(
106
+ self,
107
+ question: str,
108
+ default: object = None,
109
+ context_message: str | None = None,
110
+ **kwargs: object,
111
+ ) -> None:
112
+ super().__init__(**kwargs)
113
+ self._question = question
114
+ self._default = default
115
+ self._context_message = context_message
116
+
117
+ def compose(self) -> ComposeResult:
118
+ default_hint = "[Y/n]" if self._default is True else "[y/N]"
119
+ yield Label(f"{self._question} {default_hint}")
120
+ if self._context_message:
121
+ yield Static(self._context_message, classes="context-msg")
122
+ yield Input(placeholder="y/n")
123
+
124
+ @on(Input.Submitted)
125
+ def _on_submit(self, event: Input.Submitted) -> None:
126
+ val = event.value.strip().lower()
127
+ if val == "":
128
+ result = self._default if self._default is not None else True
129
+ elif val in ("y", "yes"):
130
+ result = True
131
+ else:
132
+ result = False
133
+ self.post_message(PromptResult(value=result, source="user"))
134
+
135
+ def action_cancel(self) -> None:
136
+ self.post_message(PromptResult(value=None, source="cancelled"))
137
+
138
+ def action_confirm_yes(self) -> None:
139
+ self.post_message(PromptResult(value=True, source="user"))
140
+
141
+ def action_confirm_no(self) -> None:
142
+ self.post_message(PromptResult(value=False, source="user"))
143
+
144
+
145
+ class SelectWidget(Widget):
146
+ """OptionList widget for SELECT intent with max 12 visible items."""
147
+
148
+ BINDINGS = [
149
+ Binding("escape", "cancel", "Cancel", show=False),
150
+ ]
151
+
152
+ DEFAULT_CSS = """
153
+ SelectWidget {
154
+ border: solid $accent;
155
+ padding: 1 2;
156
+ height: auto;
157
+ max-height: 18;
158
+ width: 100%;
159
+ }
160
+ SelectWidget Label {
161
+ margin-bottom: 1;
162
+ }
163
+ SelectWidget .context-msg {
164
+ color: $text-muted;
165
+ margin-bottom: 1;
166
+ }
167
+ SelectWidget OptionList {
168
+ max-height: 12;
169
+ }
170
+ """
171
+
172
+ def __init__(
173
+ self,
174
+ question: str,
175
+ choices: list[object],
176
+ context_message: str | None = None,
177
+ **kwargs: object,
178
+ ) -> None:
179
+ super().__init__(**kwargs)
180
+ self._question = question
181
+ self._choices = choices
182
+ self._context_message = context_message
183
+
184
+ def compose(self) -> ComposeResult:
185
+ yield Label(self._question)
186
+ if self._context_message:
187
+ yield Static(self._context_message, classes="context-msg")
188
+ option_list = OptionList()
189
+ for choice in self._choices:
190
+ label = choice.label or choice.value # type: ignore[union-attr]
191
+ prompt = label
192
+ if choice.description: # type: ignore[union-attr]
193
+ prompt = f"{label} — {choice.description}" # type: ignore[union-attr]
194
+ option_list.add_option(
195
+ Option(prompt, id=choice.value, disabled=choice.disabled) # type: ignore[union-attr]
196
+ )
197
+ yield option_list
198
+
199
+ @on(OptionList.OptionSelected)
200
+ def _on_selected(self, event: OptionList.OptionSelected) -> None:
201
+ if event.option.id is not None:
202
+ self.post_message(PromptResult(value=event.option.id, source="user"))
203
+
204
+ def action_cancel(self) -> None:
205
+ self.post_message(PromptResult(value=None, source="cancelled"))
206
+
207
+
208
+ class MultiSelectWidget(Widget):
209
+ """Checkbox list for MULTI_SELECT intent."""
210
+
211
+ BINDINGS = [
212
+ Binding("escape", "cancel", "Cancel", show=False),
213
+ Binding("enter", "submit", "Submit", show=False),
214
+ ]
215
+
216
+ DEFAULT_CSS = """
217
+ MultiSelectWidget {
218
+ border: solid $accent;
219
+ padding: 1 2;
220
+ height: auto;
221
+ max-height: 20;
222
+ width: 100%;
223
+ }
224
+ MultiSelectWidget Label {
225
+ margin-bottom: 1;
226
+ }
227
+ MultiSelectWidget .context-msg {
228
+ color: $text-muted;
229
+ margin-bottom: 1;
230
+ }
231
+ MultiSelectWidget OptionList {
232
+ max-height: 12;
233
+ }
234
+ """
235
+
236
+ def __init__(
237
+ self,
238
+ question: str,
239
+ choices: list[object],
240
+ context_message: str | None = None,
241
+ **kwargs: object,
242
+ ) -> None:
243
+ super().__init__(**kwargs)
244
+ self._question = question
245
+ self._choices = choices
246
+ self._context_message = context_message
247
+ self._selected: set[str] = set()
248
+
249
+ def compose(self) -> ComposeResult:
250
+ yield Label(f"{self._question} [dim](Space to toggle, Enter to confirm)[/]")
251
+ if self._context_message:
252
+ yield Static(self._context_message, classes="context-msg")
253
+ option_list = OptionList()
254
+ for choice in self._choices:
255
+ label = choice.label or choice.value # type: ignore[union-attr]
256
+ prompt = f"☐ {label}"
257
+ if choice.description: # type: ignore[union-attr]
258
+ prompt = f"☐ {label} — {choice.description}" # type: ignore[union-attr]
259
+ option_list.add_option(
260
+ Option(prompt, id=choice.value, disabled=choice.disabled) # type: ignore[union-attr]
261
+ )
262
+ yield option_list
263
+
264
+ @on(OptionList.OptionSelected)
265
+ def _on_toggle(self, event: OptionList.OptionSelected) -> None:
266
+ """Toggle selection on the chosen option."""
267
+ if event.option.id is None:
268
+ return
269
+ opt_id = event.option.id
270
+ if opt_id in self._selected:
271
+ self._selected.discard(opt_id)
272
+ else:
273
+ self._selected.add(opt_id)
274
+ # Update display to reflect selection state
275
+ option_list = self.query_one(OptionList)
276
+ idx = event.option_index
277
+ choice = self._choices[idx]
278
+ label = choice.label or choice.value # type: ignore[union-attr]
279
+ check = "☑" if opt_id in self._selected else "☐"
280
+ desc = f" — {choice.description}" if choice.description else "" # type: ignore[union-attr]
281
+ option_list.replace_option_prompt(idx, f"{check} {label}{desc}")
282
+
283
+ def action_submit(self) -> None:
284
+ self.post_message(PromptResult(value=list(self._selected), source="user"))
285
+
286
+ def action_cancel(self) -> None:
287
+ self.post_message(PromptResult(value=None, source="cancelled"))
288
+
289
+
290
+ class TextInputWidget(Widget):
291
+ """Text input widget for TEXT_INPUT intent."""
292
+
293
+ BINDINGS = [
294
+ Binding("escape", "cancel", "Cancel", show=False),
295
+ ]
296
+
297
+ DEFAULT_CSS = """
298
+ TextInputWidget {
299
+ border: solid $accent;
300
+ padding: 1 2;
301
+ height: auto;
302
+ width: 100%;
303
+ }
304
+ TextInputWidget Label {
305
+ margin-bottom: 1;
306
+ }
307
+ TextInputWidget .context-msg {
308
+ color: $text-muted;
309
+ margin-bottom: 1;
310
+ }
311
+ """
312
+
313
+ def __init__(
314
+ self,
315
+ question: str,
316
+ placeholder: str | None = None,
317
+ default: object = None,
318
+ context_message: str | None = None,
319
+ **kwargs: object,
320
+ ) -> None:
321
+ super().__init__(**kwargs)
322
+ self._question = question
323
+ self._placeholder = placeholder or ""
324
+ self._default = default
325
+ self._context_message = context_message
326
+
327
+ def compose(self) -> ComposeResult:
328
+ yield Label(self._question)
329
+ if self._context_message:
330
+ yield Static(self._context_message, classes="context-msg")
331
+ default_str = str(self._default) if self._default is not None else ""
332
+ yield Input(placeholder=self._placeholder, value=default_str)
333
+
334
+ @on(Input.Submitted)
335
+ def _on_submit(self, event: Input.Submitted) -> None:
336
+ value = event.value
337
+ if not value and self._default is not None:
338
+ value = str(self._default)
339
+ self.post_message(PromptResult(value=value, source="user"))
340
+
341
+ def action_cancel(self) -> None:
342
+ self.post_message(PromptResult(value=None, source="cancelled"))
343
+
344
+
345
+ class SecretInputWidget(Widget):
346
+ """Masked input widget for SECRET_INPUT intent."""
347
+
348
+ BINDINGS = [
349
+ Binding("escape", "cancel", "Cancel", show=False),
350
+ ]
351
+
352
+ DEFAULT_CSS = """
353
+ SecretInputWidget {
354
+ border: solid $accent;
355
+ padding: 1 2;
356
+ height: auto;
357
+ width: 100%;
358
+ }
359
+ SecretInputWidget Label {
360
+ margin-bottom: 1;
361
+ }
362
+ SecretInputWidget .context-msg {
363
+ color: $text-muted;
364
+ margin-bottom: 1;
365
+ }
366
+ """
367
+
368
+ def __init__(
369
+ self,
370
+ question: str,
371
+ placeholder: str | None = None,
372
+ context_message: str | None = None,
373
+ **kwargs: object,
374
+ ) -> None:
375
+ super().__init__(**kwargs)
376
+ self._question = question
377
+ self._placeholder = placeholder or ""
378
+ self._context_message = context_message
379
+
380
+ def compose(self) -> ComposeResult:
381
+ yield Label(self._question)
382
+ if self._context_message:
383
+ yield Static(self._context_message, classes="context-msg")
384
+ yield Input(placeholder=self._placeholder, password=True)
385
+
386
+ @on(Input.Submitted)
387
+ def _on_submit(self, event: Input.Submitted) -> None:
388
+ self.post_message(PromptResult(value=event.value, source="user"))
389
+
390
+ def action_cancel(self) -> None:
391
+ self.post_message(PromptResult(value=None, source="cancelled"))
392
+
393
+
394
+ class AcknowledgeWidget(Widget):
395
+ """Any-key dismiss widget for ACKNOWLEDGE intent."""
396
+
397
+ BINDINGS = [
398
+ Binding("escape", "dismiss", "Dismiss", show=False),
399
+ ]
400
+
401
+ DEFAULT_CSS = """
402
+ AcknowledgeWidget {
403
+ border: solid $success;
404
+ padding: 1 2;
405
+ height: auto;
406
+ width: 100%;
407
+ }
408
+ AcknowledgeWidget Label {
409
+ margin-bottom: 1;
410
+ }
411
+ AcknowledgeWidget .context-msg {
412
+ color: $text-muted;
413
+ margin-bottom: 1;
414
+ }
415
+ """
416
+
417
+ def __init__(
418
+ self,
419
+ question: str,
420
+ context_message: str | None = None,
421
+ **kwargs: object,
422
+ ) -> None:
423
+ super().__init__(**kwargs)
424
+ self._question = question
425
+ self._context_message = context_message
426
+
427
+ def compose(self) -> ComposeResult:
428
+ yield Label(self._question)
429
+ if self._context_message:
430
+ yield Static(self._context_message, classes="context-msg")
431
+ yield Label("[dim]Press any key to continue...[/]")
432
+
433
+ def on_key(self, event: object) -> None:
434
+ self.post_message(PromptResult(value=True, source="user"))
435
+
436
+ def action_dismiss(self) -> None:
437
+ self.post_message(PromptResult(value=True, source="user"))
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.4
2
+ Name: functualize-inline
3
+ Version: 0.1.0
4
+ Summary: Textual inline interactivity plugin for functualize prompts
5
+ Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: functualize<1.0.0,>=0.1.0
15
+ Requires-Dist: textual>=0.40.0
16
+ Provides-Extra: dev
17
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
18
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # functualize-inline
22
+
23
+ > **Status: Published** — Independently installable from PyPI.
24
+
25
+ Textual-based inline interactivity provider for the functualize framework. This plugin renders rich terminal widgets (confirmations, selections, text inputs, progress bars) inline within the terminal using [Textual's](https://textual.textualize.io/) inline mode — without taking over the full screen. When Textual is unavailable (non-TTY, CI environments), it gracefully falls back to plain CLI `input()` prompts.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install functualize-inline
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from functualize.plugin import PromptIntent, PromptRequest
37
+ from functualize_inline import InlinePlugin
38
+
39
+ plugin = InlinePlugin()
40
+
41
+ request = PromptRequest(
42
+ intent=PromptIntent.CONFIRM_NEUTRAL,
43
+ question="Deploy to staging?",
44
+ default=True,
45
+ )
46
+ response = plugin.collect(request)
47
+ print(f"User answered: {response.value} (source: {response.source})")
48
+ ```
49
+
50
+ ## Features
51
+
52
+ - **Rich inline widgets** — Confirmation dialogs, single/multi-select lists, text inputs, secret inputs, and acknowledgment prompts rendered with Textual styling
53
+ - **Automatic CLI fallback** — Detects non-TTY environments and falls back to plain `input()` prompts so jobs run unattended in CI
54
+ - **Structured output rendering** — Implements `OutputRenderer` protocol with `render_log`, `render_phase`, and `render_progress` for formatted terminal output with icons and progress bars
55
+ - **Timeout support** — Prompts can auto-dismiss after a configurable timeout, returning the default value
56
+ - **Entry-point auto-discovery** — Install the package and it registers itself as an interactivity provider via the `functualize.interactivity_providers` entry point
57
+
58
+ ## API Reference
59
+
60
+ Public classes and functions exported by this plugin:
61
+
62
+ ### `functualize_inline` (top-level)
63
+
64
+ - `InlinePlugin` — Main plugin class implementing `InputProvider` and `OutputRenderer` protocols. Entry point for prompt collection and terminal output rendering.
65
+
66
+ ### `functualize_inline.apps`
67
+
68
+ - `InlinePromptApp` — A short-lived Textual `App` (inline mode) that mounts a prompt widget and returns the user's response as a `(value, source)` tuple.
69
+
70
+ ### `functualize_inline.widgets`
71
+
72
+ - `PromptResult` — Textual `Message` posted by widgets when the user provides a response. Carries `.value` and `.source` attributes.
73
+ - `ConfirmDestructiveWidget` — Red-bordered widget requiring the user to type "yes" to confirm dangerous actions.
74
+ - `ConfirmNeutralWidget` — Standard Y/n confirmation widget with keyboard shortcuts.
75
+ - `SelectWidget` — Single-select `OptionList` widget (max 12 visible items).
76
+ - `MultiSelectWidget` — Checkbox-style multi-select widget with toggle and submit.
77
+ - `TextInputWidget` — Free-text input with optional placeholder and default value.
78
+ - `SecretInputWidget` — Masked password input widget.
79
+ - `AcknowledgeWidget` — Press-any-key dismiss widget for informational prompts.
80
+
81
+ ## Development
82
+
83
+ Run plugin tests:
84
+
85
+ ```bash
86
+ uv run pytest plugins/functualize-inline/tests/ -v
87
+ ```
88
+
89
+ Run linting and formatting:
90
+
91
+ ```bash
92
+ uv run ruff check plugins/functualize-inline/
93
+ uv run ruff format plugins/functualize-inline/
94
+ ```
@@ -0,0 +1,9 @@
1
+ functualize_inline/__init__.py,sha256=EPrl-Tphos3YAb157RKNK3YGoi-WQ46FeZuQUUpdAyE,165
2
+ functualize_inline/apps.py,sha256=YKLoR_zO1-j1P48OBqKXL5drwZzP0bBs9_05mNmP1Zs,2177
3
+ functualize_inline/plugin.py,sha256=y08i93tyDWBnc4OeeQgThgq7PyrZKzxcZBVRYJV2MJs,13418
4
+ functualize_inline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ functualize_inline/widgets.py,sha256=8IS7lJjeJ-AF08lpDYpMi0j2s9yYUTWShlK554fTuls,13099
6
+ functualize_inline-0.1.0.dist-info/METADATA,sha256=TOxFEmeH1cw9xyQRHtmB8CxsvgmUyJqie-I_qjC9sTI,3932
7
+ functualize_inline-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ functualize_inline-0.1.0.dist-info/entry_points.txt,sha256=GmkurdMYBiBTjp1xRCsuZ_kMoEgydhjPuYp2XbAosiE,79
9
+ functualize_inline-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [functualize.interactivity_providers]
2
+ inline = functualize_inline:InlinePlugin