connectonion 0.5.8__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 (113) hide show
  1. connectonion/__init__.py +78 -0
  2. connectonion/address.py +320 -0
  3. connectonion/agent.py +450 -0
  4. connectonion/announce.py +84 -0
  5. connectonion/asgi.py +287 -0
  6. connectonion/auto_debug_exception.py +181 -0
  7. connectonion/cli/__init__.py +3 -0
  8. connectonion/cli/browser_agent/__init__.py +5 -0
  9. connectonion/cli/browser_agent/browser.py +243 -0
  10. connectonion/cli/browser_agent/prompt.md +107 -0
  11. connectonion/cli/commands/__init__.py +1 -0
  12. connectonion/cli/commands/auth_commands.py +527 -0
  13. connectonion/cli/commands/browser_commands.py +27 -0
  14. connectonion/cli/commands/create.py +511 -0
  15. connectonion/cli/commands/deploy_commands.py +220 -0
  16. connectonion/cli/commands/doctor_commands.py +173 -0
  17. connectonion/cli/commands/init.py +469 -0
  18. connectonion/cli/commands/project_cmd_lib.py +828 -0
  19. connectonion/cli/commands/reset_commands.py +149 -0
  20. connectonion/cli/commands/status_commands.py +168 -0
  21. connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
  22. connectonion/cli/docs/connectonion.md +1256 -0
  23. connectonion/cli/docs.md +123 -0
  24. connectonion/cli/main.py +148 -0
  25. connectonion/cli/templates/meta-agent/README.md +287 -0
  26. connectonion/cli/templates/meta-agent/agent.py +196 -0
  27. connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
  28. connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
  29. connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
  30. connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
  31. connectonion/cli/templates/minimal/README.md +56 -0
  32. connectonion/cli/templates/minimal/agent.py +40 -0
  33. connectonion/cli/templates/playwright/README.md +118 -0
  34. connectonion/cli/templates/playwright/agent.py +336 -0
  35. connectonion/cli/templates/playwright/prompt.md +102 -0
  36. connectonion/cli/templates/playwright/requirements.txt +3 -0
  37. connectonion/cli/templates/web-research/agent.py +122 -0
  38. connectonion/connect.py +128 -0
  39. connectonion/console.py +539 -0
  40. connectonion/debug_agent/__init__.py +13 -0
  41. connectonion/debug_agent/agent.py +45 -0
  42. connectonion/debug_agent/prompts/debug_assistant.md +72 -0
  43. connectonion/debug_agent/runtime_inspector.py +406 -0
  44. connectonion/debug_explainer/__init__.py +10 -0
  45. connectonion/debug_explainer/explain_agent.py +114 -0
  46. connectonion/debug_explainer/explain_context.py +263 -0
  47. connectonion/debug_explainer/explainer_prompt.md +29 -0
  48. connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
  49. connectonion/debugger_ui.py +1039 -0
  50. connectonion/decorators.py +208 -0
  51. connectonion/events.py +248 -0
  52. connectonion/execution_analyzer/__init__.py +9 -0
  53. connectonion/execution_analyzer/execution_analysis.py +93 -0
  54. connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
  55. connectonion/host.py +579 -0
  56. connectonion/interactive_debugger.py +342 -0
  57. connectonion/llm.py +801 -0
  58. connectonion/llm_do.py +307 -0
  59. connectonion/logger.py +300 -0
  60. connectonion/prompt_files/__init__.py +1 -0
  61. connectonion/prompt_files/analyze_contact.md +62 -0
  62. connectonion/prompt_files/eval_expected.md +12 -0
  63. connectonion/prompt_files/react_evaluate.md +11 -0
  64. connectonion/prompt_files/react_plan.md +16 -0
  65. connectonion/prompt_files/reflect.md +22 -0
  66. connectonion/prompts.py +144 -0
  67. connectonion/relay.py +200 -0
  68. connectonion/static/docs.html +688 -0
  69. connectonion/tool_executor.py +279 -0
  70. connectonion/tool_factory.py +186 -0
  71. connectonion/tool_registry.py +105 -0
  72. connectonion/trust.py +166 -0
  73. connectonion/trust_agents.py +71 -0
  74. connectonion/trust_functions.py +88 -0
  75. connectonion/tui/__init__.py +57 -0
  76. connectonion/tui/divider.py +39 -0
  77. connectonion/tui/dropdown.py +251 -0
  78. connectonion/tui/footer.py +31 -0
  79. connectonion/tui/fuzzy.py +56 -0
  80. connectonion/tui/input.py +278 -0
  81. connectonion/tui/keys.py +35 -0
  82. connectonion/tui/pick.py +130 -0
  83. connectonion/tui/providers.py +155 -0
  84. connectonion/tui/status_bar.py +163 -0
  85. connectonion/usage.py +161 -0
  86. connectonion/useful_events_handlers/__init__.py +16 -0
  87. connectonion/useful_events_handlers/reflect.py +116 -0
  88. connectonion/useful_plugins/__init__.py +20 -0
  89. connectonion/useful_plugins/calendar_plugin.py +163 -0
  90. connectonion/useful_plugins/eval.py +139 -0
  91. connectonion/useful_plugins/gmail_plugin.py +162 -0
  92. connectonion/useful_plugins/image_result_formatter.py +127 -0
  93. connectonion/useful_plugins/re_act.py +78 -0
  94. connectonion/useful_plugins/shell_approval.py +159 -0
  95. connectonion/useful_tools/__init__.py +44 -0
  96. connectonion/useful_tools/diff_writer.py +192 -0
  97. connectonion/useful_tools/get_emails.py +183 -0
  98. connectonion/useful_tools/gmail.py +1596 -0
  99. connectonion/useful_tools/google_calendar.py +613 -0
  100. connectonion/useful_tools/memory.py +380 -0
  101. connectonion/useful_tools/microsoft_calendar.py +604 -0
  102. connectonion/useful_tools/outlook.py +488 -0
  103. connectonion/useful_tools/send_email.py +205 -0
  104. connectonion/useful_tools/shell.py +97 -0
  105. connectonion/useful_tools/slash_command.py +201 -0
  106. connectonion/useful_tools/terminal.py +285 -0
  107. connectonion/useful_tools/todo_list.py +241 -0
  108. connectonion/useful_tools/web_fetch.py +216 -0
  109. connectonion/xray.py +467 -0
  110. connectonion-0.5.8.dist-info/METADATA +741 -0
  111. connectonion-0.5.8.dist-info/RECORD +113 -0
  112. connectonion-0.5.8.dist-info/WHEEL +4 -0
  113. connectonion-0.5.8.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,31 @@
1
+ """
2
+ Footer - Simple tips display.
3
+
4
+ Usage:
5
+ from connectonion.tui import Footer
6
+
7
+ footer = Footer(["? help", "/ commands", "@ contacts"])
8
+ console.print(footer.render())
9
+ """
10
+
11
+ from rich.text import Text
12
+
13
+
14
+ class Footer:
15
+ """Simple footer - displays what you give it."""
16
+
17
+ def __init__(self, tips: list[str]):
18
+ """
19
+ Args:
20
+ tips: List of tips to display
21
+ """
22
+ self.tips = tips
23
+
24
+ def render(self) -> Text:
25
+ """Render tips."""
26
+ out = Text()
27
+ for i, tip in enumerate(self.tips):
28
+ out.append(tip, style="dim")
29
+ if i < len(self.tips) - 1:
30
+ out.append(" ", style="dim")
31
+ return out
@@ -0,0 +1,56 @@
1
+ """Fuzzy matching utilities."""
2
+
3
+ from rich.text import Text
4
+
5
+
6
+ def fuzzy_match(query: str, text: str) -> tuple[bool, int, list[int]]:
7
+ """Fuzzy match query against text.
8
+
9
+ Args:
10
+ query: Search query
11
+ text: Text to match against
12
+
13
+ Returns:
14
+ (matched, score, positions) - Higher score = better match
15
+ """
16
+ if not query:
17
+ return True, 0, []
18
+
19
+ query = query.lower()
20
+ text_lower = text.lower()
21
+
22
+ positions = []
23
+ query_idx = 0
24
+ last_match = -1
25
+ score = 0
26
+
27
+ for i, char in enumerate(text_lower):
28
+ if query_idx < len(query) and char == query[query_idx]:
29
+ positions.append(i)
30
+ # Consecutive match bonus
31
+ if last_match == i - 1:
32
+ score += 10
33
+ # Word boundary bonus
34
+ if i == 0 or text[i-1] in '/_-. ':
35
+ score += 5
36
+ score += 1
37
+ last_match = i
38
+ query_idx += 1
39
+
40
+ matched = query_idx == len(query)
41
+ return matched, score if matched else 0, positions
42
+
43
+
44
+ def highlight_match(text: str, positions: list[int]) -> Text:
45
+ """Highlight matched characters in Rich Text.
46
+
47
+ Uses green for matched chars (ConnectOnion theme).
48
+ """
49
+ result = Text()
50
+ pos_set = set(positions)
51
+ for i, char in enumerate(text):
52
+ if i in pos_set:
53
+ result.append(char, style="bold magenta") # Violet for matched chars
54
+ else:
55
+ result.append(char)
56
+ return result
@@ -0,0 +1,278 @@
1
+ """Input - smart input component with triggers and autocomplete.
2
+
3
+ Clean, minimal design that works on light and dark terminals.
4
+ Inspired by powerlevel10k terminal prompts.
5
+ """
6
+
7
+ import random
8
+ from typing import Optional
9
+ from rich.console import Console, Group
10
+ from rich.text import Text
11
+ from rich.live import Live
12
+
13
+ from .keys import read_key
14
+ from .dropdown import Dropdown
15
+ from .providers import FileProvider
16
+
17
+
18
+ # Color palette - works on both light and dark terminals
19
+ COLORS = {
20
+ "prompt": "bold magenta",
21
+ "text": "default", # User input in default terminal color
22
+ "filter": "bold green",
23
+ "cursor": "reverse",
24
+ "hint": "dim",
25
+ "tip": "dim italic",
26
+ }
27
+
28
+ # Global counter for rotating tips
29
+ _input_count = 0
30
+
31
+
32
+ class Input:
33
+ """Smart input with trigger-based autocomplete.
34
+
35
+ Usage:
36
+ from connectonion.tui import Input, FileProvider
37
+
38
+ # Simple
39
+ text = Input().run()
40
+
41
+ # With autocomplete
42
+ text = Input(triggers={"@": FileProvider()}).run()
43
+
44
+ # With hints (always visible) and tips (rotating)
45
+ text = Input(
46
+ hints=["/ commands", "@ contacts", "Enter submit"],
47
+ tips=["Try /today for briefing", "Join our Discord!"],
48
+ divider=True,
49
+ ).run()
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ prompt: str = None,
55
+ triggers: dict = None,
56
+ hints: list[str] = None,
57
+ tips: list[str] = None,
58
+ divider: bool = False,
59
+ max_visible: int = 8,
60
+ console: Console = None,
61
+ style: str = "modern",
62
+ ):
63
+ """
64
+ Args:
65
+ prompt: Custom prompt text
66
+ triggers: Dict of {char: Provider} for autocomplete
67
+ hints: Always-visible keyboard hints (e.g., ["/ commands", "Enter submit"])
68
+ tips: Rotating tips shown occasionally (e.g., ["Try /today", "Join Discord"])
69
+ divider: Add horizontal dividers around input
70
+ max_visible: Max dropdown items
71
+ console: Rich console
72
+ style: "modern", "minimal", or "classic"
73
+ """
74
+ self.prompt = prompt
75
+ self.triggers = triggers or {}
76
+ self.hints = hints
77
+ self.tips = tips
78
+ self.divider = divider
79
+ self.max_visible = max_visible
80
+ self.console = console or Console()
81
+ self.style = style
82
+
83
+ # State
84
+ self.buffer = ""
85
+ self.active_trigger = None
86
+ self.filter_text = ""
87
+ self.dropdown = Dropdown(max_visible=max_visible)
88
+
89
+ def _render_prompt(self) -> Text:
90
+ """Render prompt based on style."""
91
+ if self.prompt:
92
+ return Text(self.prompt)
93
+
94
+ prompts = {"modern": "❯ ", "minimal": "> ", "classic": "$ "}
95
+ return Text(prompts.get(self.style, "❯ "), style=COLORS["prompt"])
96
+
97
+ def _get_rotating_tip(self) -> str | None:
98
+ """Get a rotating tip (shows every 4 inputs, 60% chance)."""
99
+ global _input_count
100
+ if not self.tips:
101
+ return None
102
+ # Show tip every 4 rounds with 60% probability
103
+ if _input_count % 4 == 0 and random.random() < 0.6:
104
+ return self.tips[(_input_count // 4) % len(self.tips)]
105
+ return None
106
+
107
+ def _render(self) -> Group:
108
+ """Render the input UI."""
109
+ parts = []
110
+
111
+ # Top divider (very dim solid line, full width)
112
+ if self.divider:
113
+ width = self.console.width or 80
114
+ parts.append(Text("─" * width, style="dim bright_black"))
115
+
116
+ # Input line: prompt + buffer + filter + cursor
117
+ line = self._render_prompt()
118
+ if self.buffer:
119
+ line.append(self.buffer) # Default terminal color
120
+ if self.active_trigger:
121
+ line.append(self.filter_text, style=COLORS["filter"])
122
+ line.append(" ", style=COLORS["cursor"])
123
+ parts.append(line)
124
+
125
+ # Dropdown
126
+ if self.active_trigger and not self.dropdown.is_empty:
127
+ parts.append(Text())
128
+ parts.append(self.dropdown.render())
129
+
130
+ # Bottom divider (very dim solid line, full width)
131
+ if self.divider:
132
+ width = self.console.width or 80
133
+ parts.append(Text("─" * width, style="dim bright_black"))
134
+
135
+ # Hints (always visible, under divider)
136
+ if self.hints:
137
+ hint_line = Text()
138
+ for i, hint in enumerate(self.hints):
139
+ hint_line.append(hint, style=COLORS["hint"])
140
+ if i < len(self.hints) - 1:
141
+ hint_line.append(" ")
142
+ parts.append(hint_line)
143
+
144
+ # Rotating tip (occasional, under hints)
145
+ tip = self._get_rotating_tip()
146
+ if tip:
147
+ parts.append(Text(tip, style=COLORS["tip"]))
148
+
149
+ return Group(*parts)
150
+
151
+ def _accept_selection(self) -> bool:
152
+ """Accept dropdown selection. Returns True if navigating directory."""
153
+ value = self.dropdown.selected_value
154
+ if value is None:
155
+ return False
156
+
157
+ # Directory navigation for FileProvider
158
+ if isinstance(value, str) and value.endswith('/'):
159
+ provider = self.triggers.get(self.active_trigger)
160
+ if isinstance(provider, FileProvider):
161
+ provider.enter(value)
162
+ self.filter_text = ""
163
+ self._update_dropdown()
164
+ return True
165
+
166
+ # Accept selection
167
+ if self.active_trigger and self.buffer.endswith(self.active_trigger):
168
+ self.buffer = self.buffer[:-len(self.active_trigger)]
169
+ self.buffer += str(value)
170
+ self._exit_autocomplete()
171
+ return False
172
+
173
+ def _update_dropdown(self):
174
+ """Update dropdown from provider."""
175
+ provider = self.triggers.get(self.active_trigger)
176
+ if provider:
177
+ self.dropdown.set_items(provider.search(self.filter_text))
178
+ else:
179
+ self.dropdown.clear()
180
+
181
+ def _exit_autocomplete(self):
182
+ """Exit autocomplete mode."""
183
+ self.active_trigger = None
184
+ self.filter_text = ""
185
+ self.dropdown.clear()
186
+ for provider in self.triggers.values():
187
+ if isinstance(provider, FileProvider):
188
+ provider.context = ""
189
+
190
+ def run(self) -> str:
191
+ """Run input loop. Returns entered text."""
192
+ global _input_count
193
+ _input_count += 1
194
+
195
+ with Live(self._render(), console=self.console, auto_refresh=False) as live:
196
+ while True:
197
+ key = read_key()
198
+
199
+ # Enter - submit or accept selection
200
+ if key in ('\r', '\n'):
201
+ if self.active_trigger:
202
+ if not self.dropdown.is_empty:
203
+ if self._accept_selection():
204
+ live.update(self._render(), refresh=True)
205
+ continue
206
+ live.update(self._render(), refresh=True)
207
+ else:
208
+ self._exit_autocomplete()
209
+ live.update(self._render(), refresh=True)
210
+ else:
211
+ return self.buffer
212
+
213
+ # Tab - accept selection
214
+ elif key == '\t':
215
+ if self.active_trigger and not self.dropdown.is_empty:
216
+ if self._accept_selection():
217
+ live.update(self._render(), refresh=True)
218
+ continue
219
+ live.update(self._render(), refresh=True)
220
+
221
+ # Escape - cancel autocomplete
222
+ elif key == 'esc':
223
+ if self.active_trigger:
224
+ if self.buffer.endswith(self.active_trigger):
225
+ self.buffer = self.buffer[:-1]
226
+ self._exit_autocomplete()
227
+ live.update(self._render(), refresh=True)
228
+
229
+ # Ctrl+C / Ctrl+D
230
+ elif key == '\x03':
231
+ raise KeyboardInterrupt()
232
+ elif key == '\x04':
233
+ raise EOFError()
234
+
235
+ # Backspace
236
+ elif key in ('\x7f', '\x08'):
237
+ if self.active_trigger:
238
+ if self.filter_text:
239
+ self.filter_text = self.filter_text[:-1]
240
+ self._update_dropdown()
241
+ else:
242
+ provider = self.triggers.get(self.active_trigger)
243
+ if isinstance(provider, FileProvider) and provider.context:
244
+ provider.back()
245
+ self._update_dropdown()
246
+ else:
247
+ if self.buffer.endswith(self.active_trigger):
248
+ self.buffer = self.buffer[:-1]
249
+ self._exit_autocomplete()
250
+ live.update(self._render(), refresh=True)
251
+ elif self.buffer:
252
+ self.buffer = self.buffer[:-1]
253
+ live.update(self._render(), refresh=True)
254
+
255
+ # Arrow keys
256
+ elif key == 'up' and self.active_trigger:
257
+ self.dropdown.up()
258
+ live.update(self._render(), refresh=True)
259
+ elif key == 'down' and self.active_trigger:
260
+ self.dropdown.down()
261
+ live.update(self._render(), refresh=True)
262
+
263
+ # Trigger char
264
+ elif key in self.triggers:
265
+ self.active_trigger = key
266
+ self.filter_text = ""
267
+ self.buffer += key
268
+ self._update_dropdown()
269
+ live.update(self._render(), refresh=True)
270
+
271
+ # Regular input
272
+ elif key.isprintable():
273
+ if self.active_trigger:
274
+ self.filter_text += key
275
+ self._update_dropdown()
276
+ else:
277
+ self.buffer += key
278
+ live.update(self._render(), refresh=True)
@@ -0,0 +1,35 @@
1
+ """Low-level keyboard input primitives."""
2
+
3
+ import sys
4
+
5
+
6
+ def getch() -> str:
7
+ """Read single character without waiting for Enter."""
8
+ try:
9
+ import termios
10
+ import tty
11
+ fd = sys.stdin.fileno()
12
+ old = termios.tcgetattr(fd)
13
+ tty.setraw(fd)
14
+ ch = sys.stdin.read(1)
15
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
16
+ return ch
17
+ except ImportError:
18
+ import msvcrt
19
+ return msvcrt.getch().decode('utf-8', errors='ignore')
20
+
21
+
22
+ def read_key() -> str:
23
+ """Read key with arrow/escape sequence handling.
24
+
25
+ Returns:
26
+ Single char, or 'up'/'down'/'left'/'right' for arrows, 'esc' for escape
27
+ """
28
+ ch = getch()
29
+ if ch == '\x1b':
30
+ ch2 = getch()
31
+ if ch2 == '[':
32
+ ch3 = getch()
33
+ return {'A': 'up', 'B': 'down', 'C': 'right', 'D': 'left'}.get(ch3, 'esc')
34
+ return 'esc'
35
+ return ch
@@ -0,0 +1,130 @@
1
+ """
2
+ Purpose: Single-select menu component with keyboard navigation for terminal-based option selection
3
+ LLM-Note:
4
+ Dependencies: imports from [sys, rich.console, rich.live, rich.text, .keys] | imported by [tui/__init__.py, useful_tools/__init__.py] | used in CLI menus and agent tool confirmations
5
+ Data flow: caller invokes pick(title, options, other) → renders menu with Rich Live → reads keyboard via read_key() → up/down arrows move selection → Enter confirms → returns selected label string | if other=True and "Other..." selected, prompts for custom text input
6
+ State/Effects: uses Rich Live for in-place rendering | manages cursor position state | reads raw keyboard input | no file I/O
7
+ Integration: exposes pick(title, options, other, console) → str | options can be strings or (label, description) tuples | optional "Other..." for custom input | returns selected label or custom text
8
+ Performance: O(n) options rendering | single keystroke per action | real-time re-render on selection change
9
+ Errors: KeyboardInterrupt returns None or empty string | terminal restored on exit
10
+
11
+ Usage:
12
+ from connectonion.tui import pick
13
+
14
+ # Simple list selection
15
+ choice = pick("Pick a color", ["Red", "Green", "Blue"])
16
+
17
+ # With descriptions
18
+ choice = pick("Send email?", [
19
+ ("Yes, send it", "Send immediately"),
20
+ ("Auto approve", "Skip for this recipient"),
21
+ ])
22
+
23
+ # With "Other" option for custom input
24
+ choice = pick("Continue?", ["Yes", "No"], other=True)
25
+ """
26
+
27
+ import sys
28
+ from rich.console import Console
29
+ from rich.live import Live
30
+ from rich.text import Text
31
+ from .keys import read_key
32
+
33
+
34
+ def pick(title: str, options: list, other: bool = False, console: Console = None) -> str:
35
+ """Single-select menu with keyboard navigation.
36
+
37
+ Args:
38
+ title: Question to display
39
+ options: List of strings or (label, description) tuples
40
+ other: Add "Other..." option for custom text input
41
+ console: Optional Rich console
42
+
43
+ Returns:
44
+ Selected option label, or custom text if "Other" chosen
45
+ """
46
+ console = console or Console()
47
+
48
+ # Normalize options to (label, description) tuples
49
+ items = []
50
+ for opt in options:
51
+ if isinstance(opt, str):
52
+ items.append((opt, ""))
53
+ else:
54
+ items.append((opt[0], opt[1] if len(opt) > 1 else ""))
55
+ if other:
56
+ items.append(("Other...", ""))
57
+
58
+ selected = 0
59
+ total = len(items)
60
+ lines = total + 4 # title + blank + options + blank + footer
61
+
62
+ def render() -> Text:
63
+ out = Text()
64
+ out.append(f"{title}\n\n", style="bold")
65
+ for i, (label, desc) in enumerate(items):
66
+ if i == selected:
67
+ out.append(f" ❯ {i+1} {label}", style="bold cyan")
68
+ else:
69
+ out.append(f" {i+1} ", style="dim cyan")
70
+ out.append(label, style="dim")
71
+ if desc:
72
+ out.append(f" {desc}", style="dim")
73
+ out.append("\n")
74
+ # Footer with key hints
75
+ out.append("\n")
76
+ out.append("↑↓", style="cyan")
77
+ out.append(" navigate ", style="dim")
78
+ out.append("1-9", style="cyan")
79
+ out.append(" jump ", style="dim")
80
+ out.append("Enter", style="cyan")
81
+ out.append(" select", style="dim")
82
+ return out
83
+
84
+ def show_result(text: str):
85
+ sys.stdout.write(f"\033[{lines}A\033[J") # Clear menu
86
+ result = Text()
87
+ result.append(f"{title} ", style="bold")
88
+ result.append("✓ ", style="green")
89
+ result.append(text, style="dim")
90
+ console.print(result)
91
+
92
+ def get_other_input() -> str:
93
+ sys.stdout.write(f"\033[{lines}A\033[J") # Clear menu
94
+ console.print(f"[bold]{title}[/]")
95
+ return input(" → ")
96
+
97
+ def handle_select(idx: int) -> str:
98
+ if other and idx == total - 1:
99
+ return get_other_input()
100
+ label = items[idx][0]
101
+ show_result(label)
102
+ return label
103
+
104
+ # Hide cursor and run
105
+ print("\033[?25l", end="", flush=True)
106
+ try:
107
+ with Live(render(), console=console, auto_refresh=False) as live:
108
+ while True:
109
+ key = read_key()
110
+
111
+ if key == 'up':
112
+ selected = (selected - 1) % total
113
+ live.update(render(), refresh=True)
114
+ elif key == 'down':
115
+ selected = (selected + 1) % total
116
+ live.update(render(), refresh=True)
117
+ elif key in ('\r', '\n'):
118
+ live.stop()
119
+ print("\033[?25h", end="", flush=True)
120
+ return handle_select(selected)
121
+ elif key.isdigit() and 0 < int(key) <= total:
122
+ live.stop()
123
+ print("\033[?25h", end="", flush=True)
124
+ return handle_select(int(key) - 1)
125
+ elif key == '\x03':
126
+ raise KeyboardInterrupt()
127
+ elif key == '\x04':
128
+ raise EOFError()
129
+ finally:
130
+ print("\033[?25h", end="", flush=True)
@@ -0,0 +1,155 @@
1
+ """Providers for CommandPalette and Input autocomplete."""
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol, runtime_checkable, Union
5
+
6
+ from .fuzzy import fuzzy_match
7
+ from .dropdown import DropdownItem
8
+
9
+
10
+ @runtime_checkable
11
+ class Provider(Protocol):
12
+ """Protocol for autocomplete providers."""
13
+
14
+ def search(self, query: str) -> list[Union[DropdownItem, tuple]]:
15
+ """Return matches as DropdownItem or (display, value, score, positions) tuple."""
16
+ ...
17
+
18
+
19
+ class StaticProvider:
20
+ """Provider for static list of items.
21
+
22
+ Supports multiple input formats:
23
+ - (display, value) - simple tuple
24
+ - (display, value, description) - with description
25
+ - (display, value, description, icon) - with icon
26
+ - DropdownItem - full control
27
+
28
+ Usage:
29
+ # Simple commands
30
+ provider = StaticProvider([
31
+ ("/today", "/today"),
32
+ ("/inbox", "/inbox"),
33
+ ])
34
+
35
+ # With descriptions
36
+ provider = StaticProvider([
37
+ ("/today", "/today", "Daily email briefing"),
38
+ ("/inbox", "/inbox", "Show recent emails"),
39
+ ])
40
+
41
+ # With icons
42
+ provider = StaticProvider([
43
+ ("/today", "/today", "Daily briefing", "📅"),
44
+ ("/inbox", "/inbox", "Show emails", "📥"),
45
+ ])
46
+ """
47
+
48
+ def __init__(self, items: list):
49
+ """
50
+ Args:
51
+ items: List of tuples or DropdownItem objects
52
+ """
53
+ self.items = self._normalize_items(items)
54
+
55
+ def _normalize_items(self, items: list) -> list[tuple]:
56
+ """Convert items to normalized format (display, value, description, icon)."""
57
+ normalized = []
58
+ for item in items:
59
+ if isinstance(item, DropdownItem):
60
+ normalized.append((item.display, item.value, item.description, item.icon))
61
+ elif len(item) == 2:
62
+ normalized.append((item[0], item[1], "", ""))
63
+ elif len(item) == 3:
64
+ normalized.append((item[0], item[1], item[2], ""))
65
+ elif len(item) >= 4:
66
+ normalized.append((item[0], item[1], item[2], item[3]))
67
+ return normalized
68
+
69
+ def search(self, query: str) -> list[DropdownItem]:
70
+ results = []
71
+ for display, value, description, icon in self.items:
72
+ matched, score, positions = fuzzy_match(query, display)
73
+ if matched:
74
+ results.append(DropdownItem(
75
+ display=display,
76
+ value=value,
77
+ score=score,
78
+ positions=positions,
79
+ description=description,
80
+ icon=icon,
81
+ ))
82
+ return sorted(results, key=lambda x: -x.score)
83
+
84
+
85
+ class FileProvider:
86
+ """Provider for file system navigation with directory traversal."""
87
+
88
+ def __init__(
89
+ self,
90
+ root: Path = None,
91
+ show_hidden: bool = False,
92
+ dirs_only: bool = False,
93
+ files_only: bool = False,
94
+ ):
95
+ self.root = Path(root) if root else Path(".")
96
+ self.show_hidden = show_hidden
97
+ self.dirs_only = dirs_only
98
+ self.files_only = files_only
99
+ self._context = "" # Current directory for nested navigation
100
+
101
+ @property
102
+ def context(self) -> str:
103
+ return self._context
104
+
105
+ @context.setter
106
+ def context(self, value: str):
107
+ self._context = value
108
+
109
+ def search(self, query: str) -> list[DropdownItem]:
110
+ """Search files in current context directory."""
111
+ base = self.root / self._context if self._context else self.root
112
+ if not base.exists():
113
+ return []
114
+
115
+ results = []
116
+ for f in base.iterdir():
117
+ if not self.show_hidden and f.name.startswith('.'):
118
+ continue
119
+ if self.dirs_only and not f.is_dir():
120
+ continue
121
+ if self.files_only and f.is_dir():
122
+ continue
123
+
124
+ is_dir = f.is_dir()
125
+ name = f.name + ("/" if is_dir else "")
126
+ matched, score, positions = fuzzy_match(query, name)
127
+
128
+ if matched:
129
+ full_path = (self._context + name) if self._context else name
130
+ # Icons are handled by Dropdown based on filename
131
+ results.append(DropdownItem(
132
+ display=name,
133
+ value=full_path,
134
+ score=score,
135
+ positions=positions,
136
+ ))
137
+
138
+ # Sort: directories first, then by score, then alphabetically
139
+ results.sort(key=lambda x: (not x.display.endswith('/'), -x.score, x.display.lower()))
140
+ return results
141
+
142
+ def enter(self, path: str) -> bool:
143
+ """Enter a directory. Returns True if successful."""
144
+ if path.endswith('/'):
145
+ self._context = path
146
+ return True
147
+ return False
148
+
149
+ def back(self) -> bool:
150
+ """Go up one directory. Returns True if moved up."""
151
+ if self._context:
152
+ parts = self._context.rstrip('/').rsplit('/', 1)
153
+ self._context = parts[0] + '/' if len(parts) > 1 else ""
154
+ return True
155
+ return False