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.
- functualize_inline/__init__.py +5 -0
- functualize_inline/apps.py +74 -0
- functualize_inline/plugin.py +335 -0
- functualize_inline/py.typed +0 -0
- functualize_inline/widgets.py +437 -0
- functualize_inline-0.1.0.dist-info/METADATA +94 -0
- functualize_inline-0.1.0.dist-info/RECORD +9 -0
- functualize_inline-0.1.0.dist-info/WHEEL +4 -0
- functualize_inline-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -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,,
|