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.
- connectonion/__init__.py +78 -0
- connectonion/address.py +320 -0
- connectonion/agent.py +450 -0
- connectonion/announce.py +84 -0
- connectonion/asgi.py +287 -0
- connectonion/auto_debug_exception.py +181 -0
- connectonion/cli/__init__.py +3 -0
- connectonion/cli/browser_agent/__init__.py +5 -0
- connectonion/cli/browser_agent/browser.py +243 -0
- connectonion/cli/browser_agent/prompt.md +107 -0
- connectonion/cli/commands/__init__.py +1 -0
- connectonion/cli/commands/auth_commands.py +527 -0
- connectonion/cli/commands/browser_commands.py +27 -0
- connectonion/cli/commands/create.py +511 -0
- connectonion/cli/commands/deploy_commands.py +220 -0
- connectonion/cli/commands/doctor_commands.py +173 -0
- connectonion/cli/commands/init.py +469 -0
- connectonion/cli/commands/project_cmd_lib.py +828 -0
- connectonion/cli/commands/reset_commands.py +149 -0
- connectonion/cli/commands/status_commands.py +168 -0
- connectonion/cli/docs/co-vibecoding-principles-docs-contexts-all-in-one.md +2010 -0
- connectonion/cli/docs/connectonion.md +1256 -0
- connectonion/cli/docs.md +123 -0
- connectonion/cli/main.py +148 -0
- connectonion/cli/templates/meta-agent/README.md +287 -0
- connectonion/cli/templates/meta-agent/agent.py +196 -0
- connectonion/cli/templates/meta-agent/prompts/answer_prompt.md +9 -0
- connectonion/cli/templates/meta-agent/prompts/docs_retrieve_prompt.md +15 -0
- connectonion/cli/templates/meta-agent/prompts/metagent.md +71 -0
- connectonion/cli/templates/meta-agent/prompts/think_prompt.md +18 -0
- connectonion/cli/templates/minimal/README.md +56 -0
- connectonion/cli/templates/minimal/agent.py +40 -0
- connectonion/cli/templates/playwright/README.md +118 -0
- connectonion/cli/templates/playwright/agent.py +336 -0
- connectonion/cli/templates/playwright/prompt.md +102 -0
- connectonion/cli/templates/playwright/requirements.txt +3 -0
- connectonion/cli/templates/web-research/agent.py +122 -0
- connectonion/connect.py +128 -0
- connectonion/console.py +539 -0
- connectonion/debug_agent/__init__.py +13 -0
- connectonion/debug_agent/agent.py +45 -0
- connectonion/debug_agent/prompts/debug_assistant.md +72 -0
- connectonion/debug_agent/runtime_inspector.py +406 -0
- connectonion/debug_explainer/__init__.py +10 -0
- connectonion/debug_explainer/explain_agent.py +114 -0
- connectonion/debug_explainer/explain_context.py +263 -0
- connectonion/debug_explainer/explainer_prompt.md +29 -0
- connectonion/debug_explainer/root_cause_analysis_prompt.md +43 -0
- connectonion/debugger_ui.py +1039 -0
- connectonion/decorators.py +208 -0
- connectonion/events.py +248 -0
- connectonion/execution_analyzer/__init__.py +9 -0
- connectonion/execution_analyzer/execution_analysis.py +93 -0
- connectonion/execution_analyzer/execution_analysis_prompt.md +47 -0
- connectonion/host.py +579 -0
- connectonion/interactive_debugger.py +342 -0
- connectonion/llm.py +801 -0
- connectonion/llm_do.py +307 -0
- connectonion/logger.py +300 -0
- connectonion/prompt_files/__init__.py +1 -0
- connectonion/prompt_files/analyze_contact.md +62 -0
- connectonion/prompt_files/eval_expected.md +12 -0
- connectonion/prompt_files/react_evaluate.md +11 -0
- connectonion/prompt_files/react_plan.md +16 -0
- connectonion/prompt_files/reflect.md +22 -0
- connectonion/prompts.py +144 -0
- connectonion/relay.py +200 -0
- connectonion/static/docs.html +688 -0
- connectonion/tool_executor.py +279 -0
- connectonion/tool_factory.py +186 -0
- connectonion/tool_registry.py +105 -0
- connectonion/trust.py +166 -0
- connectonion/trust_agents.py +71 -0
- connectonion/trust_functions.py +88 -0
- connectonion/tui/__init__.py +57 -0
- connectonion/tui/divider.py +39 -0
- connectonion/tui/dropdown.py +251 -0
- connectonion/tui/footer.py +31 -0
- connectonion/tui/fuzzy.py +56 -0
- connectonion/tui/input.py +278 -0
- connectonion/tui/keys.py +35 -0
- connectonion/tui/pick.py +130 -0
- connectonion/tui/providers.py +155 -0
- connectonion/tui/status_bar.py +163 -0
- connectonion/usage.py +161 -0
- connectonion/useful_events_handlers/__init__.py +16 -0
- connectonion/useful_events_handlers/reflect.py +116 -0
- connectonion/useful_plugins/__init__.py +20 -0
- connectonion/useful_plugins/calendar_plugin.py +163 -0
- connectonion/useful_plugins/eval.py +139 -0
- connectonion/useful_plugins/gmail_plugin.py +162 -0
- connectonion/useful_plugins/image_result_formatter.py +127 -0
- connectonion/useful_plugins/re_act.py +78 -0
- connectonion/useful_plugins/shell_approval.py +159 -0
- connectonion/useful_tools/__init__.py +44 -0
- connectonion/useful_tools/diff_writer.py +192 -0
- connectonion/useful_tools/get_emails.py +183 -0
- connectonion/useful_tools/gmail.py +1596 -0
- connectonion/useful_tools/google_calendar.py +613 -0
- connectonion/useful_tools/memory.py +380 -0
- connectonion/useful_tools/microsoft_calendar.py +604 -0
- connectonion/useful_tools/outlook.py +488 -0
- connectonion/useful_tools/send_email.py +205 -0
- connectonion/useful_tools/shell.py +97 -0
- connectonion/useful_tools/slash_command.py +201 -0
- connectonion/useful_tools/terminal.py +285 -0
- connectonion/useful_tools/todo_list.py +241 -0
- connectonion/useful_tools/web_fetch.py +216 -0
- connectonion/xray.py +467 -0
- connectonion-0.5.8.dist-info/METADATA +741 -0
- connectonion-0.5.8.dist-info/RECORD +113 -0
- connectonion-0.5.8.dist-info/WHEEL +4 -0
- 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)
|
connectonion/tui/keys.py
ADDED
|
@@ -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
|
connectonion/tui/pick.py
ADDED
|
@@ -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
|