diff --git a/connpy/ai.py b/connpy/ai.py
index b4779b1..d986ac7 100755
--- a/connpy/ai.py
+++ b/connpy/ai.py
@@ -1231,6 +1231,18 @@ class ai:
os_info = node_info.get("os", "unknown")
node_name = node_info.get("name", "unknown")
+ # Load vendor-specific command reference if available
+ vendor_reference = ""
+ if os_info and os_info != "unknown":
+ try:
+ os_filename = os_info.lower().replace(" ", "_")
+ ref_path = os.path.join(self.config.defaultdir, "ai_references", f"{os_filename}.md")
+ if os.path.exists(ref_path):
+ with open(ref_path, "r") as f:
+ vendor_reference = f.read().strip()
+ except Exception:
+ pass
+
system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
Rules:
1. Answer the user's question directly based on the Terminal Context.
@@ -1255,6 +1267,9 @@ Terminal Context:
Device OS: {os_info}
Node: {node_name}"""
+
+ if vendor_reference:
+ system_prompt += f"\n\nVendor Command Reference:\n{vendor_reference}"
messages = [
{"role": "system", "content": system_prompt},
diff --git a/connpy/core.py b/connpy/core.py
index 6a850c3..7a7d850 100755
--- a/connpy/core.py
+++ b/connpy/core.py
@@ -397,7 +397,8 @@ class node:
def _child_read_ready():
try:
- data = os.read(child_fd, 4096)
+ # Increase buffer to 64KB for better high-speed handling
+ data = os.read(child_fd, 65536)
if data:
child_reader_queue.put_nowait(data)
else:
@@ -422,8 +423,8 @@ class node:
buffer = ""
if hasattr(self, 'mylog'):
raw = self.mylog.getvalue().decode(errors='replace')
- buffer = self._logclean(raw, var=True)
- # Pass the full buffer to the handler so the user can adjust context size interactively
+ # Move heavy log cleaning to a thread
+ buffer = await asyncio.to_thread(self._logclean, raw, True)
# Build node info from available metadata
node_info = {"name": getattr(self, 'unique', 'unknown'), "host": getattr(self, 'host', 'unknown')}
@@ -454,18 +455,41 @@ class node:
data = await child_reader_queue.get()
if not data:
break
-
- if skip_newlines:
- stripped = data.lstrip(b'\r\n')
- if stripped:
- skip_newlines = False
- data = stripped
- else:
- continue
-
- await local_stream.write(data)
- if hasattr(self, 'mylog'):
- self.mylog.write(data)
+
+ # Batching Optimization: Drain the queue to batch writes during high-volume bursts
+ # Helps the terminal parse ANSI faster and reduces syscalls.
+ chunks = [data]
+ while not child_reader_queue.empty():
+ try:
+ extra = child_reader_queue.get_nowait()
+ if not extra:
+ chunks.append(b'') # Re-put EOF later or handle it
+ break
+ chunks.append(extra)
+ except asyncio.QueueEmpty:
+ break
+
+ has_eof = chunks[-1] == b''
+ if has_eof:
+ chunks.pop()
+
+ if chunks:
+ combined_data = b''.join(chunks)
+ if skip_newlines:
+ stripped = combined_data.lstrip(b'\r\n')
+ if stripped:
+ skip_newlines = False
+ combined_data = stripped
+ else:
+ if has_eof: break
+ continue
+
+ await local_stream.write(combined_data)
+ if hasattr(self, 'mylog'):
+ self.mylog.write(combined_data)
+
+ if has_eof:
+ break
async def keepalive_task():
while True:
@@ -484,16 +508,17 @@ class node:
current_size = self.mylog.tell()
if current_size != prev_size:
try:
+ # Move heavy log cleaning to a thread to avoid freezing the interaction loop
+ raw_log = self.mylog.getvalue().decode(errors='replace')
+ cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
with open(self.logfile, "w") as f:
- f.write(self._logclean(self.mylog.getvalue().decode(), True))
+ f.write(cleaned_log)
prev_size = current_size
except Exception:
pass
try:
- # gather runs until any task completes (or we just let them run until EOF breaks them)
- # Ingress breaks on user EOF. Egress breaks on child EOF.
- # We want to exit if either happens, so return_exceptions=False, but we need to cancel the others.
+ # We wait for either the user (ingress) or the child (egress) to finish
tasks = [
asyncio.create_task(ingress_task()),
asyncio.create_task(egress_task())
@@ -502,9 +527,34 @@ class node:
tasks.append(asyncio.create_task(keepalive_task()))
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
tasks.append(asyncio.create_task(savelog_task()))
- done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
- for p in pending:
- p.cancel()
+
+ done, pending = await asyncio.wait(
+ [tasks[0], tasks[1]],
+ return_when=asyncio.FIRST_COMPLETED
+ )
+
+ # If ingress finished first (user quit), give egress a small window to catch up
+ # on the remaining output in the queue.
+ if tasks[0] in done and tasks[1] not in done:
+ try:
+ await asyncio.wait_for(tasks[1], timeout=0.2)
+ except (asyncio.TimeoutError, asyncio.CancelledError):
+ pass
+
+ for t in tasks:
+ if t not in done:
+ t.cancel()
+
+ # Final log sync on thread to avoid losing last lines
+ if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
+ try:
+ raw_log = self.mylog.getvalue().decode(errors='replace')
+ cleaned_log = await asyncio.to_thread(self._logclean, raw_log, True)
+ with open(self.logfile, "w") as f:
+ f.write(cleaned_log)
+ except Exception:
+ pass
+
finally:
loop.remove_reader(child_fd)
try:
@@ -651,9 +701,9 @@ class node:
if cmd_text:
blocks.append((cmd_byte_positions[i], preview[:80]))
- # Add synthetic "current prompt" block (zero context)
- last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
- blocks.append((len(raw_bytes), last_line[:80]))
+ # Add synthetic "current prompt" block (zero context)
+ last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
+ blocks.append((len(raw_bytes), last_line[:80]))
context_cmd = [1]
total_cmds = len(blocks)
@@ -744,12 +794,12 @@ class node:
bottom_toolbar=get_toolbar
)
- if cancelled[0] or not question.strip():
+ if cancelled[0] or not question.strip() or question.strip() == "CANCEL":
console.print("\n[dim]Copilot cancelled.[/dim]")
os.write(child_fd, b'\x15\r')
return
- active_buffer = get_active_buffer()
+ active_buffer = get_active_buffer()
# 3. Llamar al AI con spinner
from .services.ai_service import AIService
diff --git a/connpy/grpc_layer/connpy_pb2.py b/connpy/grpc_layer/connpy_pb2.py
index 1dbfb06..82caffb 100644
--- a/connpy/grpc_layer/connpy_pb2.py
+++ b/connpy/grpc_layer/connpy_pb2.py
@@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onnpy.proto\x12\x06\x63onnpy\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1bgoogle/protobuf/empty.proto\"\x8a\x01\n\x0fInteractRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04sftp\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65\x62ug\x18\x03 \x01(\x08\x12\x12\n\nstdin_data\x18\x04 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x05 \x01(\x05\x12\x0c\n\x04rows\x18\x06 \x01(\x05\x12\x1e\n\x16\x63onnection_params_json\x18\x07 \x01(\t\"O\n\x10InteractResponse\x12\x13\n\x0bstdout_data\x18\x01 \x01(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x15\n\rerror_message\x18\x03 \x01(\t\"7\n\rFilterRequest\x12\x12\n\nfilter_str\x18\x01 \x01(\t\x12\x12\n\nformat_str\x18\x02 \x01(\t\"5\n\rValueResponse\x12$\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\"\x17\n\tIdRequest\x12\n\n\x02id\x18\x01 \x01(\t\"S\n\x0bNodeRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x11\n\tis_folder\x18\x03 \x01(\x08\".\n\rDeleteRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tis_folder\x18\x02 \x01(\x08\"\x1d\n\x0cMessageValue\x12\r\n\x05value\x18\x01 \x01(\t\";\n\x0bMoveRequest\x12\x0e\n\x06src_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64st_id\x18\x02 \x01(\t\x12\x0c\n\x04\x63opy\x18\x03 \x01(\x08\"W\n\x0b\x42ulkRequest\x12\x0b\n\x03ids\x18\x01 \x03(\t\x12\r\n\x05hosts\x18\x02 \x03(\t\x12,\n\x0b\x63ommon_data\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"7\n\x0eStructResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"/\n\x0eProfileRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07resolve\x18\x02 \x01(\x08\"6\n\rStructRequest\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1e\n\rStringRequest\x12\r\n\x05value\x18\x01 \x01(\t\"\x1f\n\x0eStringResponse\x12\r\n\x05value\x18\x01 \x01(\t\"C\n\rUpdateRequest\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value\"B\n\rPluginRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\x0e\n\x06update\x18\x03 \x01(\x08\"\xa5\x01\n\nRunRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x0e\n\x06\x66older\x18\x03 \x01(\t\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x10\n\x08parallel\x18\x05 \x01(\x05\x12%\n\x04vars\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x12\x0c\n\x04name\x18\x08 \x01(\t\"\xb8\x01\n\x0bTestRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x10\n\x08\x65xpected\x18\x03 \x03(\t\x12\x0e\n\x06\x66older\x18\x04 \x01(\t\x12\x0e\n\x06prompt\x18\x05 \x01(\t\x12\x10\n\x08parallel\x18\x06 \x01(\x05\x12%\n\x04vars\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x08 \x01(\x05\x12\x0c\n\x04name\x18\t \x01(\t\"A\n\rScriptRequest\x12\x0e\n\x06param1\x18\x01 \x01(\t\x12\x0e\n\x06param2\x18\x02 \x01(\t\x12\x10\n\x08parallel\x18\x03 \x01(\x05\"3\n\rExportRequest\x12\x11\n\tfile_path\x18\x01 \x01(\t\x12\x0f\n\x07\x66olders\x18\x02 \x03(\t\"\x1c\n\x0bListRequest\x12\r\n\x05items\x18\x01 \x03(\t\"\xa6\x02\n\nAskRequest\x12\x12\n\ninput_text\x18\x01 \x01(\t\x12\x0e\n\x06\x64ryrun\x18\x02 \x01(\x08\x12,\n\x0c\x63hat_history\x18\x03 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x12\n\nsession_id\x18\x04 \x01(\t\x12\r\n\x05\x64\x65\x62ug\x18\x05 \x01(\x08\x12\x16\n\x0e\x65ngineer_model\x18\x06 \x01(\t\x12\x18\n\x10\x65ngineer_api_key\x18\x07 \x01(\t\x12\x17\n\x0f\x61rchitect_model\x18\x08 \x01(\t\x12\x19\n\x11\x61rchitect_api_key\x18\t \x01(\t\x12\r\n\x05trust\x18\n \x01(\x08\x12\x1b\n\x13\x63onfirmation_answer\x18\x0b \x01(\t\x12\x11\n\tinterrupt\x18\x0c \x01(\x08\"\xc8\x01\n\nAIResponse\x12\x12\n\ntext_chunk\x18\x01 \x01(\t\x12\x10\n\x08is_final\x18\x02 \x01(\x08\x12,\n\x0b\x66ull_result\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x15\n\rstatus_update\x18\x04 \x01(\t\x12\x15\n\rdebug_message\x18\x05 \x01(\t\x12\x1d\n\x15requires_confirmation\x18\x06 \x01(\x08\x12\x19\n\x11important_message\x18\x07 \x01(\t\"\x1d\n\x0c\x42oolResponse\x12\r\n\x05value\x18\x01 \x01(\x08\"C\n\x0fProviderRequest\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x03 \x01(\t\"\x1b\n\nIntRequest\x12\r\n\x05value\x18\x01 \x01(\x05\"p\n\rNodeRunResult\x12\x11\n\tunique_id\x18\x01 \x01(\t\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12,\n\x0btest_result\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"m\n\x12\x46ullReplaceRequest\x12,\n\x0b\x63onnections\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\x08profiles\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"X\n\x0e\x43opilotRequest\x12\x17\n\x0fterminal_buffer\x18\x01 \x01(\t\x12\x15\n\ruser_question\x18\x02 \x01(\t\x12\x16\n\x0enode_info_json\x18\x03 \x01(\t\"U\n\x0f\x43opilotResponse\x12\x10\n\x08\x63ommands\x18\x01 \x03(\t\x12\r\n\x05guide\x18\x02 \x01(\t\x12\x12\n\nrisk_level\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t2\xe1\x07\n\x0bNodeService\x12<\n\nlist_nodes\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12>\n\x0clist_folders\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x10get_node_details\x12\x11.connpy.IdRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0e\x65xplode_unique\x12\x11.connpy.IdRequest\x1a\x15.connpy.ValueResponse\"\x00\x12\x42\n\x0egenerate_cache\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x61\x64\x64_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x0bupdate_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12>\n\x0b\x64\x65lete_node\x12\x15.connpy.DeleteRequest\x1a\x16.google.protobuf.Empty\"\x00\x12:\n\tmove_node\x12\x13.connpy.MoveRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x62ulk_add\x12\x13.connpy.BulkRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\x16validate_parent_folder\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n\rinteract_node\x12\x17.connpy.InteractRequest\x1a\x18.connpy.InteractResponse\"\x00(\x01\x30\x01\x12\x44\n\x0c\x66ull_replace\x12\x1a.connpy.FullReplaceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\rget_inventory\x12\x16.google.protobuf.Empty\x1a\x1a.connpy.FullReplaceRequest\"\x00\x32\x96\x03\n\x0eProfileService\x12?\n\rlist_profiles\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x0bget_profile\x12\x16.connpy.ProfileRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0b\x61\x64\x64_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11resolve_node_data\x12\x15.connpy.StructRequest\x1a\x16.connpy.StructResponse\"\x00\x12=\n\x0e\x64\x65lete_profile\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12?\n\x0eupdate_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xae\x03\n\rConfigService\x12@\n\x0cget_settings\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x0fget_default_dir\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StringResponse\"\x00\x12\x44\n\x11set_config_folder\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x41\n\x0eupdate_setting\x12\x15.connpy.UpdateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10\x65ncrypt_password\x12\x15.connpy.StringRequest\x1a\x16.connpy.StringResponse\"\x00\x12H\n\x15\x61pply_theme_from_file\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xca\x02\n\rPluginService\x12?\n\x0clist_plugins\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12=\n\nadd_plugin\x12\x15.connpy.PluginRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\rdelete_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\renable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\x0e\x64isable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\x9b\x02\n\x10\x45xecutionService\x12=\n\x0crun_commands\x12\x12.connpy.RunRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12?\n\rtest_commands\x12\x13.connpy.TestRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12\x41\n\x0erun_cli_script\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x12\x44\n\x11run_yaml_playbook\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xe2\x01\n\x13ImportExportService\x12\x41\n\x0e\x65xport_to_file\x12\x15.connpy.ExportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10import_from_file\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xd0\x03\n\tAIService\x12\x33\n\x03\x61sk\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12\x38\n\x07\x63onfirm\x12\x15.connpy.StringRequest\x1a\x14.connpy.BoolResponse\"\x00\x12@\n\x0b\x61sk_copilot\x12\x16.connpy.CopilotRequest\x1a\x17.connpy.CopilotResponse\"\x00\x12@\n\rlist_sessions\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x41\n\x0e\x64\x65lete_session\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n\x12\x63onfigure_provider\x12\x17.connpy.ProviderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11load_session_data\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xc2\x02\n\rSystemService\x12\x39\n\tstart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\tdebug_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x08stop_api\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12;\n\x0brestart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x0eget_api_status\x12\x16.google.protobuf.Empty\x1a\x14.connpy.BoolResponse\"\x00\x62\x06proto3')
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63onnpy.proto\x12\x06\x63onnpy\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1bgoogle/protobuf/empty.proto\"\xdc\x01\n\x0fInteractRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x0c\n\x04sftp\x18\x02 \x01(\x08\x12\r\n\x05\x64\x65\x62ug\x18\x03 \x01(\x08\x12\x12\n\nstdin_data\x18\x04 \x01(\x0c\x12\x0c\n\x04\x63ols\x18\x05 \x01(\x05\x12\x0c\n\x04rows\x18\x06 \x01(\x05\x12\x1e\n\x16\x63onnection_params_json\x18\x07 \x01(\t\x12\x18\n\x10\x63opilot_question\x18\x08 \x01(\t\x12\x16\n\x0e\x63opilot_action\x18\t \x01(\t\x12\x1e\n\x16\x63opilot_context_buffer\x18\n \x01(\t\"\xe4\x01\n\x10InteractResponse\x12\x13\n\x0bstdout_data\x18\x01 \x01(\x0c\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x15\n\rerror_message\x18\x03 \x01(\t\x12\x16\n\x0e\x63opilot_prompt\x18\x04 \x01(\x08\x12\x1e\n\x16\x63opilot_buffer_preview\x18\x05 \x01(\t\x12\x1d\n\x15\x63opilot_response_json\x18\x06 \x01(\t\x12\x1e\n\x16\x63opilot_node_info_json\x18\x07 \x01(\t\x12\x1c\n\x14\x63opilot_stream_chunk\x18\x08 \x01(\t\"7\n\rFilterRequest\x12\x12\n\nfilter_str\x18\x01 \x01(\t\x12\x12\n\nformat_str\x18\x02 \x01(\t\"5\n\rValueResponse\x12$\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x16.google.protobuf.Value\"\x17\n\tIdRequest\x12\n\n\x02id\x18\x01 \x01(\t\"S\n\x0bNodeRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12%\n\x04\x64\x61ta\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x11\n\tis_folder\x18\x03 \x01(\x08\".\n\rDeleteRequest\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tis_folder\x18\x02 \x01(\x08\"\x1d\n\x0cMessageValue\x12\r\n\x05value\x18\x01 \x01(\t\";\n\x0bMoveRequest\x12\x0e\n\x06src_id\x18\x01 \x01(\t\x12\x0e\n\x06\x64st_id\x18\x02 \x01(\t\x12\x0c\n\x04\x63opy\x18\x03 \x01(\x08\"W\n\x0b\x42ulkRequest\x12\x0b\n\x03ids\x18\x01 \x03(\t\x12\r\n\x05hosts\x18\x02 \x03(\t\x12,\n\x0b\x63ommon_data\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\"7\n\x0eStructResponse\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"/\n\x0eProfileRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07resolve\x18\x02 \x01(\x08\"6\n\rStructRequest\x12%\n\x04\x64\x61ta\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\"\x1e\n\rStringRequest\x12\r\n\x05value\x18\x01 \x01(\t\"\x1f\n\x0eStringResponse\x12\r\n\x05value\x18\x01 \x01(\t\"C\n\rUpdateRequest\x12\x0b\n\x03key\x18\x01 \x01(\t\x12%\n\x05value\x18\x02 \x01(\x0b\x32\x16.google.protobuf.Value\"B\n\rPluginRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0bsource_file\x18\x02 \x01(\t\x12\x0e\n\x06update\x18\x03 \x01(\x08\"\xa5\x01\n\nRunRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x0e\n\x06\x66older\x18\x03 \x01(\t\x12\x0e\n\x06prompt\x18\x04 \x01(\t\x12\x10\n\x08parallel\x18\x05 \x01(\x05\x12%\n\x04vars\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x07 \x01(\x05\x12\x0c\n\x04name\x18\x08 \x01(\t\"\xb8\x01\n\x0bTestRequest\x12\r\n\x05nodes\x18\x01 \x03(\t\x12\x10\n\x08\x63ommands\x18\x02 \x03(\t\x12\x10\n\x08\x65xpected\x18\x03 \x03(\t\x12\x0e\n\x06\x66older\x18\x04 \x01(\t\x12\x0e\n\x06prompt\x18\x05 \x01(\t\x12\x10\n\x08parallel\x18\x06 \x01(\x05\x12%\n\x04vars\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x0f\n\x07timeout\x18\x08 \x01(\x05\x12\x0c\n\x04name\x18\t \x01(\t\"A\n\rScriptRequest\x12\x0e\n\x06param1\x18\x01 \x01(\t\x12\x0e\n\x06param2\x18\x02 \x01(\t\x12\x10\n\x08parallel\x18\x03 \x01(\x05\"3\n\rExportRequest\x12\x11\n\tfile_path\x18\x01 \x01(\t\x12\x0f\n\x07\x66olders\x18\x02 \x03(\t\"\x1c\n\x0bListRequest\x12\r\n\x05items\x18\x01 \x03(\t\"\xa6\x02\n\nAskRequest\x12\x12\n\ninput_text\x18\x01 \x01(\t\x12\x0e\n\x06\x64ryrun\x18\x02 \x01(\x08\x12,\n\x0c\x63hat_history\x18\x03 \x01(\x0b\x32\x16.google.protobuf.Value\x12\x12\n\nsession_id\x18\x04 \x01(\t\x12\r\n\x05\x64\x65\x62ug\x18\x05 \x01(\x08\x12\x16\n\x0e\x65ngineer_model\x18\x06 \x01(\t\x12\x18\n\x10\x65ngineer_api_key\x18\x07 \x01(\t\x12\x17\n\x0f\x61rchitect_model\x18\x08 \x01(\t\x12\x19\n\x11\x61rchitect_api_key\x18\t \x01(\t\x12\r\n\x05trust\x18\n \x01(\x08\x12\x1b\n\x13\x63onfirmation_answer\x18\x0b \x01(\t\x12\x11\n\tinterrupt\x18\x0c \x01(\x08\"\xc8\x01\n\nAIResponse\x12\x12\n\ntext_chunk\x18\x01 \x01(\t\x12\x10\n\x08is_final\x18\x02 \x01(\x08\x12,\n\x0b\x66ull_result\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x15\n\rstatus_update\x18\x04 \x01(\t\x12\x15\n\rdebug_message\x18\x05 \x01(\t\x12\x1d\n\x15requires_confirmation\x18\x06 \x01(\x08\x12\x19\n\x11important_message\x18\x07 \x01(\t\"\x1d\n\x0c\x42oolResponse\x12\r\n\x05value\x18\x01 \x01(\x08\"C\n\x0fProviderRequest\x12\x10\n\x08provider\x18\x01 \x01(\t\x12\r\n\x05model\x18\x02 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x03 \x01(\t\"\x1b\n\nIntRequest\x12\r\n\x05value\x18\x01 \x01(\x05\"p\n\rNodeRunResult\x12\x11\n\tunique_id\x18\x01 \x01(\t\x12\x0e\n\x06output\x18\x02 \x01(\t\x12\x0e\n\x06status\x18\x03 \x01(\x05\x12,\n\x0btest_result\x18\x04 \x01(\x0b\x32\x17.google.protobuf.Struct\"m\n\x12\x46ullReplaceRequest\x12,\n\x0b\x63onnections\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12)\n\x08profiles\x18\x02 \x01(\x0b\x32\x17.google.protobuf.Struct\"X\n\x0e\x43opilotRequest\x12\x17\n\x0fterminal_buffer\x18\x01 \x01(\t\x12\x15\n\ruser_question\x18\x02 \x01(\t\x12\x16\n\x0enode_info_json\x18\x03 \x01(\t\"U\n\x0f\x43opilotResponse\x12\x10\n\x08\x63ommands\x18\x01 \x03(\t\x12\r\n\x05guide\x18\x02 \x01(\t\x12\x12\n\nrisk_level\x18\x03 \x01(\t\x12\r\n\x05\x65rror\x18\x04 \x01(\t2\xe1\x07\n\x0bNodeService\x12<\n\nlist_nodes\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12>\n\x0clist_folders\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x10get_node_details\x12\x11.connpy.IdRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0e\x65xplode_unique\x12\x11.connpy.IdRequest\x1a\x15.connpy.ValueResponse\"\x00\x12\x42\n\x0egenerate_cache\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x61\x64\x64_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x0bupdate_node\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12>\n\x0b\x64\x65lete_node\x12\x15.connpy.DeleteRequest\x1a\x16.google.protobuf.Empty\"\x00\x12:\n\tmove_node\x12\x13.connpy.MoveRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\x08\x62ulk_add\x12\x13.connpy.BulkRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\x16validate_parent_folder\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x12H\n\rinteract_node\x12\x17.connpy.InteractRequest\x1a\x18.connpy.InteractResponse\"\x00(\x01\x30\x01\x12\x44\n\x0c\x66ull_replace\x12\x1a.connpy.FullReplaceRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x45\n\rget_inventory\x12\x16.google.protobuf.Empty\x1a\x1a.connpy.FullReplaceRequest\"\x00\x32\x96\x03\n\x0eProfileService\x12?\n\rlist_profiles\x12\x15.connpy.FilterRequest\x1a\x15.connpy.ValueResponse\"\x00\x12?\n\x0bget_profile\x12\x16.connpy.ProfileRequest\x1a\x16.connpy.StructResponse\"\x00\x12<\n\x0b\x61\x64\x64_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11resolve_node_data\x12\x15.connpy.StructRequest\x1a\x16.connpy.StructResponse\"\x00\x12=\n\x0e\x64\x65lete_profile\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12?\n\x0eupdate_profile\x12\x13.connpy.NodeRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xae\x03\n\rConfigService\x12@\n\x0cget_settings\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StructResponse\"\x00\x12\x43\n\x0fget_default_dir\x12\x16.google.protobuf.Empty\x1a\x16.connpy.StringResponse\"\x00\x12\x44\n\x11set_config_folder\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x41\n\x0eupdate_setting\x12\x15.connpy.UpdateRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10\x65ncrypt_password\x12\x15.connpy.StringRequest\x1a\x16.connpy.StringResponse\"\x00\x12H\n\x15\x61pply_theme_from_file\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xca\x02\n\rPluginService\x12?\n\x0clist_plugins\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12=\n\nadd_plugin\x12\x15.connpy.PluginRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\rdelete_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\renable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x12=\n\x0e\x64isable_plugin\x12\x11.connpy.IdRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\x9b\x02\n\x10\x45xecutionService\x12=\n\x0crun_commands\x12\x12.connpy.RunRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12?\n\rtest_commands\x12\x13.connpy.TestRequest\x1a\x15.connpy.NodeRunResult\"\x00\x30\x01\x12\x41\n\x0erun_cli_script\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x12\x44\n\x11run_yaml_playbook\x12\x15.connpy.ScriptRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xe2\x01\n\x13ImportExportService\x12\x41\n\x0e\x65xport_to_file\x12\x15.connpy.ExportRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x10import_from_file\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x43\n\x12set_reserved_names\x12\x13.connpy.ListRequest\x1a\x16.google.protobuf.Empty\"\x00\x32\xd0\x03\n\tAIService\x12\x33\n\x03\x61sk\x12\x12.connpy.AskRequest\x1a\x12.connpy.AIResponse\"\x00(\x01\x30\x01\x12\x38\n\x07\x63onfirm\x12\x15.connpy.StringRequest\x1a\x14.connpy.BoolResponse\"\x00\x12@\n\x0b\x61sk_copilot\x12\x16.connpy.CopilotRequest\x1a\x17.connpy.CopilotResponse\"\x00\x12@\n\rlist_sessions\x12\x16.google.protobuf.Empty\x1a\x15.connpy.ValueResponse\"\x00\x12\x41\n\x0e\x64\x65lete_session\x12\x15.connpy.StringRequest\x1a\x16.google.protobuf.Empty\"\x00\x12G\n\x12\x63onfigure_provider\x12\x17.connpy.ProviderRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x44\n\x11load_session_data\x12\x15.connpy.StringRequest\x1a\x16.connpy.StructResponse\"\x00\x32\xc2\x02\n\rSystemService\x12\x39\n\tstart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12\x39\n\tdebug_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12<\n\x08stop_api\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\"\x00\x12;\n\x0brestart_api\x12\x12.connpy.IntRequest\x1a\x16.google.protobuf.Empty\"\x00\x12@\n\x0eget_api_status\x12\x16.google.protobuf.Empty\x1a\x14.connpy.BoolResponse\"\x00\x62\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -34,81 +34,81 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'connpy_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_INTERACTREQUEST']._serialized_start=84
- _globals['_INTERACTREQUEST']._serialized_end=222
- _globals['_INTERACTRESPONSE']._serialized_start=224
- _globals['_INTERACTRESPONSE']._serialized_end=303
- _globals['_FILTERREQUEST']._serialized_start=305
- _globals['_FILTERREQUEST']._serialized_end=360
- _globals['_VALUERESPONSE']._serialized_start=362
- _globals['_VALUERESPONSE']._serialized_end=415
- _globals['_IDREQUEST']._serialized_start=417
- _globals['_IDREQUEST']._serialized_end=440
- _globals['_NODEREQUEST']._serialized_start=442
- _globals['_NODEREQUEST']._serialized_end=525
- _globals['_DELETEREQUEST']._serialized_start=527
- _globals['_DELETEREQUEST']._serialized_end=573
- _globals['_MESSAGEVALUE']._serialized_start=575
- _globals['_MESSAGEVALUE']._serialized_end=604
- _globals['_MOVEREQUEST']._serialized_start=606
- _globals['_MOVEREQUEST']._serialized_end=665
- _globals['_BULKREQUEST']._serialized_start=667
- _globals['_BULKREQUEST']._serialized_end=754
- _globals['_STRUCTRESPONSE']._serialized_start=756
- _globals['_STRUCTRESPONSE']._serialized_end=811
- _globals['_PROFILEREQUEST']._serialized_start=813
- _globals['_PROFILEREQUEST']._serialized_end=860
- _globals['_STRUCTREQUEST']._serialized_start=862
- _globals['_STRUCTREQUEST']._serialized_end=916
- _globals['_STRINGREQUEST']._serialized_start=918
- _globals['_STRINGREQUEST']._serialized_end=948
- _globals['_STRINGRESPONSE']._serialized_start=950
- _globals['_STRINGRESPONSE']._serialized_end=981
- _globals['_UPDATEREQUEST']._serialized_start=983
- _globals['_UPDATEREQUEST']._serialized_end=1050
- _globals['_PLUGINREQUEST']._serialized_start=1052
- _globals['_PLUGINREQUEST']._serialized_end=1118
- _globals['_RUNREQUEST']._serialized_start=1121
- _globals['_RUNREQUEST']._serialized_end=1286
- _globals['_TESTREQUEST']._serialized_start=1289
- _globals['_TESTREQUEST']._serialized_end=1473
- _globals['_SCRIPTREQUEST']._serialized_start=1475
- _globals['_SCRIPTREQUEST']._serialized_end=1540
- _globals['_EXPORTREQUEST']._serialized_start=1542
- _globals['_EXPORTREQUEST']._serialized_end=1593
- _globals['_LISTREQUEST']._serialized_start=1595
- _globals['_LISTREQUEST']._serialized_end=1623
- _globals['_ASKREQUEST']._serialized_start=1626
- _globals['_ASKREQUEST']._serialized_end=1920
- _globals['_AIRESPONSE']._serialized_start=1923
- _globals['_AIRESPONSE']._serialized_end=2123
- _globals['_BOOLRESPONSE']._serialized_start=2125
- _globals['_BOOLRESPONSE']._serialized_end=2154
- _globals['_PROVIDERREQUEST']._serialized_start=2156
- _globals['_PROVIDERREQUEST']._serialized_end=2223
- _globals['_INTREQUEST']._serialized_start=2225
- _globals['_INTREQUEST']._serialized_end=2252
- _globals['_NODERUNRESULT']._serialized_start=2254
- _globals['_NODERUNRESULT']._serialized_end=2366
- _globals['_FULLREPLACEREQUEST']._serialized_start=2368
- _globals['_FULLREPLACEREQUEST']._serialized_end=2477
- _globals['_COPILOTREQUEST']._serialized_start=2479
- _globals['_COPILOTREQUEST']._serialized_end=2567
- _globals['_COPILOTRESPONSE']._serialized_start=2569
- _globals['_COPILOTRESPONSE']._serialized_end=2654
- _globals['_NODESERVICE']._serialized_start=2657
- _globals['_NODESERVICE']._serialized_end=3650
- _globals['_PROFILESERVICE']._serialized_start=3653
- _globals['_PROFILESERVICE']._serialized_end=4059
- _globals['_CONFIGSERVICE']._serialized_start=4062
- _globals['_CONFIGSERVICE']._serialized_end=4492
- _globals['_PLUGINSERVICE']._serialized_start=4495
- _globals['_PLUGINSERVICE']._serialized_end=4825
- _globals['_EXECUTIONSERVICE']._serialized_start=4828
- _globals['_EXECUTIONSERVICE']._serialized_end=5111
- _globals['_IMPORTEXPORTSERVICE']._serialized_start=5114
- _globals['_IMPORTEXPORTSERVICE']._serialized_end=5340
- _globals['_AISERVICE']._serialized_start=5343
- _globals['_AISERVICE']._serialized_end=5807
- _globals['_SYSTEMSERVICE']._serialized_start=5810
- _globals['_SYSTEMSERVICE']._serialized_end=6132
+ _globals['_INTERACTREQUEST']._serialized_end=304
+ _globals['_INTERACTRESPONSE']._serialized_start=307
+ _globals['_INTERACTRESPONSE']._serialized_end=535
+ _globals['_FILTERREQUEST']._serialized_start=537
+ _globals['_FILTERREQUEST']._serialized_end=592
+ _globals['_VALUERESPONSE']._serialized_start=594
+ _globals['_VALUERESPONSE']._serialized_end=647
+ _globals['_IDREQUEST']._serialized_start=649
+ _globals['_IDREQUEST']._serialized_end=672
+ _globals['_NODEREQUEST']._serialized_start=674
+ _globals['_NODEREQUEST']._serialized_end=757
+ _globals['_DELETEREQUEST']._serialized_start=759
+ _globals['_DELETEREQUEST']._serialized_end=805
+ _globals['_MESSAGEVALUE']._serialized_start=807
+ _globals['_MESSAGEVALUE']._serialized_end=836
+ _globals['_MOVEREQUEST']._serialized_start=838
+ _globals['_MOVEREQUEST']._serialized_end=897
+ _globals['_BULKREQUEST']._serialized_start=899
+ _globals['_BULKREQUEST']._serialized_end=986
+ _globals['_STRUCTRESPONSE']._serialized_start=988
+ _globals['_STRUCTRESPONSE']._serialized_end=1043
+ _globals['_PROFILEREQUEST']._serialized_start=1045
+ _globals['_PROFILEREQUEST']._serialized_end=1092
+ _globals['_STRUCTREQUEST']._serialized_start=1094
+ _globals['_STRUCTREQUEST']._serialized_end=1148
+ _globals['_STRINGREQUEST']._serialized_start=1150
+ _globals['_STRINGREQUEST']._serialized_end=1180
+ _globals['_STRINGRESPONSE']._serialized_start=1182
+ _globals['_STRINGRESPONSE']._serialized_end=1213
+ _globals['_UPDATEREQUEST']._serialized_start=1215
+ _globals['_UPDATEREQUEST']._serialized_end=1282
+ _globals['_PLUGINREQUEST']._serialized_start=1284
+ _globals['_PLUGINREQUEST']._serialized_end=1350
+ _globals['_RUNREQUEST']._serialized_start=1353
+ _globals['_RUNREQUEST']._serialized_end=1518
+ _globals['_TESTREQUEST']._serialized_start=1521
+ _globals['_TESTREQUEST']._serialized_end=1705
+ _globals['_SCRIPTREQUEST']._serialized_start=1707
+ _globals['_SCRIPTREQUEST']._serialized_end=1772
+ _globals['_EXPORTREQUEST']._serialized_start=1774
+ _globals['_EXPORTREQUEST']._serialized_end=1825
+ _globals['_LISTREQUEST']._serialized_start=1827
+ _globals['_LISTREQUEST']._serialized_end=1855
+ _globals['_ASKREQUEST']._serialized_start=1858
+ _globals['_ASKREQUEST']._serialized_end=2152
+ _globals['_AIRESPONSE']._serialized_start=2155
+ _globals['_AIRESPONSE']._serialized_end=2355
+ _globals['_BOOLRESPONSE']._serialized_start=2357
+ _globals['_BOOLRESPONSE']._serialized_end=2386
+ _globals['_PROVIDERREQUEST']._serialized_start=2388
+ _globals['_PROVIDERREQUEST']._serialized_end=2455
+ _globals['_INTREQUEST']._serialized_start=2457
+ _globals['_INTREQUEST']._serialized_end=2484
+ _globals['_NODERUNRESULT']._serialized_start=2486
+ _globals['_NODERUNRESULT']._serialized_end=2598
+ _globals['_FULLREPLACEREQUEST']._serialized_start=2600
+ _globals['_FULLREPLACEREQUEST']._serialized_end=2709
+ _globals['_COPILOTREQUEST']._serialized_start=2711
+ _globals['_COPILOTREQUEST']._serialized_end=2799
+ _globals['_COPILOTRESPONSE']._serialized_start=2801
+ _globals['_COPILOTRESPONSE']._serialized_end=2886
+ _globals['_NODESERVICE']._serialized_start=2889
+ _globals['_NODESERVICE']._serialized_end=3882
+ _globals['_PROFILESERVICE']._serialized_start=3885
+ _globals['_PROFILESERVICE']._serialized_end=4291
+ _globals['_CONFIGSERVICE']._serialized_start=4294
+ _globals['_CONFIGSERVICE']._serialized_end=4724
+ _globals['_PLUGINSERVICE']._serialized_start=4727
+ _globals['_PLUGINSERVICE']._serialized_end=5057
+ _globals['_EXECUTIONSERVICE']._serialized_start=5060
+ _globals['_EXECUTIONSERVICE']._serialized_end=5343
+ _globals['_IMPORTEXPORTSERVICE']._serialized_start=5346
+ _globals['_IMPORTEXPORTSERVICE']._serialized_end=5572
+ _globals['_AISERVICE']._serialized_start=5575
+ _globals['_AISERVICE']._serialized_end=6039
+ _globals['_SYSTEMSERVICE']._serialized_start=6042
+ _globals['_SYSTEMSERVICE']._serialized_end=6364
# @@protoc_insertion_point(module_scope)
diff --git a/connpy/grpc_layer/connpy_pb2_grpc.py b/connpy/grpc_layer/connpy_pb2_grpc.py
index dd9b5e7..e7ceb6c 100644
--- a/connpy/grpc_layer/connpy_pb2_grpc.py
+++ b/connpy/grpc_layer/connpy_pb2_grpc.py
@@ -3,7 +3,7 @@
import grpc
import warnings
-import connpy_pb2 as connpy__pb2
+from . import connpy_pb2 as connpy__pb2
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
GRPC_GENERATED_VERSION = '1.80.0'
diff --git a/connpy/grpc_layer/server.py b/connpy/grpc_layer/server.py
index e4f56a0..d5fd47b 100644
--- a/connpy/grpc_layer/server.py
+++ b/connpy/grpc_layer/server.py
@@ -55,8 +55,13 @@ def handle_errors(func):
return wrapper
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
- def __init__(self, config):
+ def __init__(self, config, debug=False):
self.service = NodeService(config)
+ self.server_debug = debug
+ if debug:
+ from rich.console import Console
+ from ..printer import connpy_theme, get_original_stdout
+ self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
@handle_errors
def interact_node(self, request_iterator, context):
@@ -79,8 +84,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
sftp = first_req.sftp
debug = first_req.debug
- if debug:
- printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node request for: [bold cyan]{unique_id}[/bold cyan]")
+ if self.server_debug:
+ self.server_console.print(f"[debug][DEBUG][/debug] gRPC interact_node request for: [bold cyan]{unique_id}[/bold cyan]")
if first_req.connection_params_json:
import json
@@ -198,7 +203,107 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
except Exception:
pass
- asyncio.run(n._async_interact_loop(remote_stream, resize_callback))
+ async def remote_copilot_handler(buffer, node_info, stream, child_fd, cmd_byte_positions=None):
+ import json
+ import asyncio
+ import os
+
+ node_info_json = json.dumps(node_info) if node_info else ""
+ # 1. Send prompt to client
+ response_queue.put(connpy_pb2.InteractResponse(
+ copilot_prompt=True,
+ copilot_buffer_preview=buffer[-200:],
+ copilot_node_info_json=node_info_json
+ ))
+
+ # 2. Await the question from client via the copilot_queue
+ try:
+ req_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=120)
+ if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL":
+ os.write(child_fd, b'\x15\r')
+ return
+ question = req_data["question"]
+ context_buffer = req_data.get("context_buffer", "")
+ if not context_buffer:
+ context_buffer = buffer
+ except asyncio.TimeoutError:
+ os.write(child_fd, b'\x15\r')
+ return
+
+ # 3. Call AI Service with streaming
+ from ..services.ai_service import AIService
+ service = AIService(self.service.config)
+
+ def chunk_callback(chunk_text):
+ if chunk_text:
+ response_queue.put(connpy_pb2.InteractResponse(
+ copilot_stream_chunk=chunk_text
+ ))
+
+ result = await asyncio.to_thread(service.ask_copilot, context_buffer, question, node_info, chunk_callback=chunk_callback)
+
+ # 4. Send response back to client
+ response_queue.put(connpy_pb2.InteractResponse(
+ copilot_response_json=json.dumps(result)
+ ))
+
+ # 5. Wait for user action
+ try:
+ action_data = await asyncio.wait_for(remote_stream.copilot_queue.get(), timeout=60)
+ if "action" not in action_data or not action_data["action"]:
+ return
+ action = action_data["action"]
+ except asyncio.TimeoutError:
+ return
+
+ if action == "send_all":
+ commands = result.get("commands", [])
+ os.write(child_fd, b'\x15') # Ctrl+U to clear line
+ await asyncio.sleep(0.1)
+ for cmd in commands:
+ os.write(child_fd, (cmd + "\n").encode())
+ await asyncio.sleep(0.3)
+ elif action.startswith("custom:"):
+ custom_cmds = action[7:]
+ os.write(child_fd, b'\x15')
+ await asyncio.sleep(0.1)
+ for cmd in custom_cmds.split('\n'):
+ if cmd.strip():
+ os.write(child_fd, (cmd.strip() + "\n").encode())
+ await asyncio.sleep(0.3)
+ elif action not in ('cancel', 'n', 'no'):
+ # Handle numbers and ranges like "1,2,4-6"
+ try:
+ commands = result.get("commands", [])
+ selected_indices = set()
+ for part in action.split(','):
+ part = part.strip()
+ if not part: continue
+ if '-' in part:
+ start_str, end_str = part.split('-', 1)
+ start = int(start_str) - 1
+ end = int(end_str) - 1
+ for i in range(start, end + 1):
+ selected_indices.add(i)
+ else:
+ selected_indices.add(int(part) - 1)
+
+ valid_indices = sorted([i for i in selected_indices if 0 <= i < len(commands)])
+ if valid_indices:
+ os.write(child_fd, b'\x15')
+ await asyncio.sleep(0.1)
+ for idx in valid_indices:
+ os.write(child_fd, (commands[idx] + "\n").encode())
+ await asyncio.sleep(0.3)
+ else:
+ os.write(child_fd, b'\x15\r')
+ except (ValueError, IndexError):
+ os.write(child_fd, b'\x15\r')
+ else:
+ # Cancelled or invalid action
+ os.write(child_fd, b'\x15\r')
+
+ asyncio.run(n._async_interact_loop(remote_stream, resize_callback, copilot_handler=remote_copilot_handler))
except Exception as e:
pass
finally:
@@ -207,14 +312,19 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
t_loop = threading.Thread(target=run_async_loop, daemon=True)
t_loop.start()
+ def response_generator():
+ while True:
+ data = response_queue.get()
+ if data is None:
+ if self.server_debug:
+ self.server_console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
+ break
+ if isinstance(data, connpy_pb2.InteractResponse):
+ yield data
+ else:
+ yield connpy_pb2.InteractResponse(stdout_data=data)
+ yield from response_generator()
- while True:
- data = response_queue.get()
- if data is None:
- if debug:
- printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
- break
- yield connpy_pb2.InteractResponse(stdout_data=data)
@handle_errors
def list_nodes(self, request, context):
f = request.filter_str if request.filter_str else None
@@ -837,7 +947,7 @@ def serve(config, port=8048, debug=False):
interceptors = [LoggingInterceptor()] if debug else []
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
- connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config), server)
+ connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config, debug=debug), server)
connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(config), server)
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server)
plugin_servicer = PluginServicer(config)
diff --git a/connpy/grpc_layer/stubs.py b/connpy/grpc_layer/stubs.py
index db5d54d..02c2b72 100644
--- a/connpy/grpc_layer/stubs.py
+++ b/connpy/grpc_layer/stubs.py
@@ -47,9 +47,23 @@ class NodeStub:
import select
import tty
import termios
+ import queue
import os
import threading
+ request_queue = queue.Queue()
+ client_buffer_bytes = bytearray()
+ cmd_byte_positions = [0]
+ pause_stdin = [False]
+ wake_r, wake_w = os.pipe()
+
+ def pause_generator():
+ pause_stdin[0] = True
+ os.write(wake_w, b'\x00')
+
+ def resume_generator():
+ pause_stdin[0] = False
+
def request_generator():
cols, rows = 80, 24
try:
@@ -63,12 +77,31 @@ class NodeStub:
)
while True:
- r, _, _ = select.select([sys.stdin.fileno()], [], [])
- if r:
+ try:
+ while True:
+ req = request_queue.get_nowait()
+ if req is None:
+ return
+ yield req
+ except queue.Empty:
+ pass
+
+ if pause_stdin[0]:
+ import time
+ time.sleep(0.05)
+ continue
+
+ r, _, _ = select.select([sys.stdin.fileno(), wake_r], [], [], 0.05)
+ if wake_r in r:
+ os.read(wake_r, 1)
+ continue
+ if sys.stdin.fileno() in r and not pause_stdin[0]:
try:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
break
+ if b'\r' in data or b'\n' in data:
+ cmd_byte_positions.append(len(client_buffer_bytes))
yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError:
break
@@ -103,6 +136,7 @@ class NodeStub:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
+ pause_stdin[0] = False
tty.setraw(sys.stdin.fileno())
break
@@ -118,10 +152,285 @@ class NodeStub:
# Clear screen filter is only applied before success (Phase 1).
# Once the user has a prompt, Ctrl+L must work normally.
for res in response_iterator:
+ if res.copilot_prompt:
+ pause_generator()
+ import json
+ import asyncio
+ import re
+ from rich.console import Console
+ from rich.panel import Panel
+ from rich.markdown import Markdown
+ from prompt_toolkit import PromptSession
+ from prompt_toolkit.key_binding import KeyBindings
+ from prompt_toolkit.formatted_text import HTML
+ from prompt_toolkit.history import InMemoryHistory
+ from ..printer import connpy_theme
+
+ if not hasattr(self, 'copilot_history'):
+ self.copilot_history = InMemoryHistory()
+
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
+ import fcntl
+ flags = fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL)
+ fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
+ console = Console(theme=connpy_theme)
+ console.print("\n")
+ console.print(Panel(
+ "[bold cyan]AI Terminal Copilot[/bold cyan]\n"
+ "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n"
+ "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
+ border_style="cyan"
+ ))
+
+ node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
+
+ # Logic for context selection
+ blocks = []
+ raw_bytes = client_buffer_bytes
+ from ..core import node
+ dummy_node = node("dummy", "dummy") # For logclean
+
+ if cmd_byte_positions and len(cmd_byte_positions) >= 2 and raw_bytes:
+ default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
+ device_prompt = node_info.get("prompt", default_prompt)
+ prompt_re_str = re.sub(r'(?= total_lines:
+ context_lines[0] = min(50, total_lines)
+ else:
+ context_lines[0] = min(context_lines[0] + 50, total_lines)
+ else:
+ if context_cmd[0] < total_cmds:
+ context_cmd[0] += 1
+ else:
+ context_cmd[0] = 1
+ event.app.invalidate()
+
+ @bindings.add('c-down')
+ def _(event):
+ if context_mode[0] == MODE_LINES:
+ if context_lines[0] <= min(50, total_lines):
+ context_lines[0] = total_lines
+ else:
+ context_lines[0] = max(context_lines[0] - 50, min(50, total_lines))
+ else:
+ if context_cmd[0] > 1:
+ context_cmd[0] -= 1
+ else:
+ context_cmd[0] = total_cmds
+ event.app.invalidate()
+
+ @bindings.add('tab')
+ def _(event):
+ context_mode[0] = (context_mode[0] + 1) % 3
+ event.app.invalidate()
+
+ @bindings.add('escape')
+ def _(event):
+ event.app.exit(result='')
+
+ def get_current_block():
+ idx = max(0, total_cmds - context_cmd[0])
+ return idx, blocks[idx]
+
+ def get_active_buffer():
+ if context_mode[0] == MODE_LINES:
+ buffer_lines = clean_buffer.split('\n')
+ return '\n'.join(buffer_lines[-context_lines[0]:])
+
+ idx, (start, preview) = get_current_block()
+ if context_mode[0] == MODE_SINGLE and idx + 1 < total_cmds:
+ end = blocks[idx + 1][0]
+ active_raw = raw_bytes[start:end]
+ else:
+ active_raw = raw_bytes[start:]
+ return preview + "\n" + dummy_node._logclean(active_raw.decode(errors='replace'), var=True)
+
+ def get_prompt_text():
+ if context_mode[0] == MODE_LINES:
+ return HTML(f"Ask [Ctx: {context_lines[0]}/{total_lines}L]: ")
+
+ lines_count = len(get_active_buffer().split('\n'))
+ if context_mode[0] == MODE_SINGLE:
+ return HTML(f"Ask [Cmd {context_cmd[0]} ~{lines_count}L]: ")
+ else:
+ return HTML(f"Ask [Cmd {context_cmd[0]}\u2192END ~{lines_count}L]: ")
+
+ def get_toolbar():
+ mode_labels = {MODE_RANGE: "RANGE", MODE_SINGLE: "SINGLE", MODE_LINES: "LINES"}
+ mode_label = mode_labels[context_mode[0]]
+ if context_mode[0] == MODE_LINES:
+ return HTML(f"\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {mode_label}]")
+ _, (_, preview) = get_current_block()
+ return HTML(f"\u25b6 {preview} [Tab: {mode_label}]")
+
+ try:
+ session = PromptSession(history=self.copilot_history)
+ question = session.prompt(get_prompt_text, key_bindings=bindings, bottom_toolbar=get_toolbar)
+ except KeyboardInterrupt:
+ question = ""
+
+ # Switch back to raw mode immediately so Ctrl+C during streaming doesn't break gRPC
+ tty.setraw(sys.stdin.fileno())
+
+ # IMPORTANT: Enable OPOST so rich.Live renders correctly (translates \n to \r\n).
+ # Without this, the UI repeats the panel multiple times in raw mode.
+ mode = termios.tcgetattr(sys.stdin.fileno())
+ mode[1] = mode[1] | termios.OPOST
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, mode)
+
+ if not question or not question.strip() or question.strip() == "CANCEL":
+ console.print("\n[dim]Copilot cancelled.[/dim]")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_question="CANCEL"))
+ resume_generator()
+ continue
+
+ active_buffer = get_active_buffer()
+ request_queue.put(connpy_pb2.InteractRequest(copilot_question=question, copilot_context_buffer=active_buffer))
+
+ from rich.live import Live
+ live_text = "Thinking..."
+ panel = Panel(live_text, title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan")
+ result = {}
+ cancelled = False
+
+ try:
+ with Live(panel, console=console, refresh_per_second=10) as live:
+ for chunk_res in response_iterator:
+ if chunk_res.copilot_stream_chunk:
+ if live_text == "Thinking...": live_text = ""
+ live_text += chunk_res.copilot_stream_chunk
+ live.update(Panel(Markdown(live_text), title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan"))
+ elif chunk_res.copilot_response_json:
+ result = json.loads(chunk_res.copilot_response_json)
+ break
+ except KeyboardInterrupt:
+ cancelled = True
+ console.print("\n[dim]Copilot cancelled via Ctrl+C. Disconnecting...[/dim]")
+ break
+
+ if cancelled:
+ break
+
+ if result.get("error"):
+ console.print(f"[red]Error: {result['error']}[/red]")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel"))
+ resume_generator()
+ tty.setraw(sys.stdin.fileno())
+ continue
+
+ if live_text == "Thinking..." and result.get("guide"):
+ console.print(Panel(Markdown(result["guide"]), title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan"))
+
+ commands = result.get("commands", [])
+ risk = result.get("risk_level", "low")
+ risk_style = {"low": "green", "high": "yellow", "destructive": "red"}.get(risk, "green")
+
+ action_sent = "cancel"
+ if commands:
+ cmd_text = "\n".join(f" {i+1}. {cmd}" for i, cmd in enumerate(commands))
+ console.print(Panel(
+ cmd_text,
+ title=f"[bold {risk_style}]Suggested Commands [{risk.upper()}][/bold {risk_style}]",
+ border_style=risk_style
+ ))
+
+ try:
+ confirm_session = PromptSession()
+ confirm_bindings = KeyBindings()
+ @confirm_bindings.add('escape')
+ def _(event):
+ event.app.exit(result='n')
+
+ pt_color = "ansi" + risk_style
+ action = confirm_session.prompt(
+ HTML(f"<{pt_color}>Send commands? (y/n/e/number/range) [n]: {pt_color}>"),
+ key_bindings=confirm_bindings
+ )
+ except KeyboardInterrupt:
+ action = "n"
+
+ if not action.strip():
+ action = "n"
+
+ action_l = action.lower().strip()
+ if action_l in ('y', 'yes', 'all'):
+ action_sent = "send_all"
+ elif action_l.startswith('e'):
+ action_sent = f"edit_{action_l[1:]}" if len(action_l) > 1 else "edit_all"
+ # For remote editing, the client edits and sends back as custom action
+ edit_session = PromptSession()
+ cmds_to_edit = []
+ if action_sent.startswith("edit_") and action_sent[5:].isdigit():
+ idx = int(action_sent[5:]) - 1
+ if 0 <= idx < len(commands):
+ cmds_to_edit = [commands[idx]]
+ else:
+ cmds_to_edit = commands
+
+ if cmds_to_edit:
+ target_cmd = "\n".join(cmds_to_edit)
+ try:
+ edited_cmd = edit_session.prompt(
+ HTML("Edit commands (Alt+Enter or Esc,Enter to submit):\n"),
+ default=target_cmd,
+ multiline=True
+ )
+ if edited_cmd.strip():
+ action_sent = "custom:" + edited_cmd.strip()
+ else:
+ action_sent = "cancel"
+ except KeyboardInterrupt:
+ action_sent = "cancel"
+ elif action_l not in ('n', 'no', ''):
+ action_sent = action_l
+
+ console.print("[dim]Returning to session...[/dim]\n")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent))
+ resume_generator()
+ tty.setraw(sys.stdin.fileno())
+ continue
+
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
+ client_buffer_bytes.extend(res.stdout_data)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
+ os.close(wake_r)
+ os.close(wake_w)
@handle_errors
def connect_dynamic(self, connection_params, debug=False):
@@ -129,10 +438,23 @@ class NodeStub:
import select
import tty
import termios
+ import queue
import os
import json
params_json = json.dumps(connection_params)
+ request_queue = queue.Queue()
+ client_buffer_bytes = bytearray()
+ cmd_byte_positions = [0]
+ pause_stdin = [False]
+ wake_r, wake_w = os.pipe()
+
+ def pause_generator():
+ pause_stdin[0] = True
+ os.write(wake_w, b'\x00')
+
+ def resume_generator():
+ pause_stdin[0] = False
def request_generator():
cols, rows = 80, 24
@@ -148,12 +470,31 @@ class NodeStub:
)
while True:
- r, _, _ = select.select([sys.stdin.fileno()], [], [])
- if r:
+ try:
+ while True:
+ req = request_queue.get_nowait()
+ if req is None:
+ return
+ yield req
+ except queue.Empty:
+ pass
+
+ if pause_stdin[0]:
+ import time
+ time.sleep(0.05)
+ continue
+
+ r, _, _ = select.select([sys.stdin.fileno(), wake_r], [], [], 0.05)
+ if wake_r in r:
+ os.read(wake_r, 1)
+ continue
+ if sys.stdin.fileno() in r and not pause_stdin[0]:
try:
data = os.read(sys.stdin.fileno(), 1024)
if not data:
break
+ if b'\r' in data or b'\n' in data:
+ cmd_byte_positions.append(len(client_buffer_bytes))
yield connpy_pb2.InteractRequest(stdin_data=data)
except OSError:
break
@@ -189,6 +530,7 @@ class NodeStub:
# Connection established on server, show success message
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
printer.success(conn_msg)
+ pause_stdin[0] = False
tty.setraw(sys.stdin.fileno())
break
@@ -204,10 +546,285 @@ class NodeStub:
# Clear screen filter is only applied before success (Phase 1).
# Once the user has a prompt, Ctrl+L must work normally.
for res in response_iterator:
+ if res.copilot_prompt:
+ pause_generator()
+ import json
+ import asyncio
+ import re
+ from rich.console import Console
+ from rich.panel import Panel
+ from rich.markdown import Markdown
+ from prompt_toolkit import PromptSession
+ from prompt_toolkit.key_binding import KeyBindings
+ from prompt_toolkit.formatted_text import HTML
+ from prompt_toolkit.history import InMemoryHistory
+ from ..printer import connpy_theme
+
+ if not hasattr(self, 'copilot_history'):
+ self.copilot_history = InMemoryHistory()
+
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
+ import fcntl
+ flags = fcntl.fcntl(sys.stdin.fileno(), fcntl.F_GETFL)
+ fcntl.fcntl(sys.stdin.fileno(), fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
+ console = Console(theme=connpy_theme)
+ console.print("\n")
+ console.print(Panel(
+ "[bold cyan]AI Terminal Copilot[/bold cyan]\n"
+ "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel.\n"
+ "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]",
+ border_style="cyan"
+ ))
+
+ node_info = json.loads(res.copilot_node_info_json) if res.copilot_node_info_json else {}
+
+ # Logic for context selection
+ blocks = []
+ raw_bytes = client_buffer_bytes
+ from ..core import node
+ dummy_node = node("dummy", "dummy") # For logclean
+
+ if cmd_byte_positions and len(cmd_byte_positions) >= 2 and raw_bytes:
+ default_prompt = r'>$|#$|\$$|>.$|#.$|\$.$'
+ device_prompt = node_info.get("prompt", default_prompt)
+ prompt_re_str = re.sub(r'(?= total_lines:
+ context_lines[0] = min(50, total_lines)
+ else:
+ context_lines[0] = min(context_lines[0] + 50, total_lines)
+ else:
+ if context_cmd[0] < total_cmds:
+ context_cmd[0] += 1
+ else:
+ context_cmd[0] = 1
+ event.app.invalidate()
+
+ @bindings.add('c-down')
+ def _(event):
+ if context_mode[0] == MODE_LINES:
+ if context_lines[0] <= min(50, total_lines):
+ context_lines[0] = total_lines
+ else:
+ context_lines[0] = max(context_lines[0] - 50, min(50, total_lines))
+ else:
+ if context_cmd[0] > 1:
+ context_cmd[0] -= 1
+ else:
+ context_cmd[0] = total_cmds
+ event.app.invalidate()
+
+ @bindings.add('tab')
+ def _(event):
+ context_mode[0] = (context_mode[0] + 1) % 3
+ event.app.invalidate()
+
+ @bindings.add('escape')
+ def _(event):
+ event.app.exit(result='')
+
+ def get_current_block():
+ idx = max(0, total_cmds - context_cmd[0])
+ return idx, blocks[idx]
+
+ def get_active_buffer():
+ if context_mode[0] == MODE_LINES:
+ buffer_lines = clean_buffer.split('\n')
+ return '\n'.join(buffer_lines[-context_lines[0]:])
+
+ idx, (start, preview) = get_current_block()
+ if context_mode[0] == MODE_SINGLE and idx + 1 < total_cmds:
+ end = blocks[idx + 1][0]
+ active_raw = raw_bytes[start:end]
+ else:
+ active_raw = raw_bytes[start:]
+ return preview + "\n" + dummy_node._logclean(active_raw.decode(errors='replace'), var=True)
+
+ def get_prompt_text():
+ if context_mode[0] == MODE_LINES:
+ return HTML(f"Ask [Ctx: {context_lines[0]}/{total_lines}L]: ")
+
+ lines_count = len(get_active_buffer().split('\n'))
+ if context_mode[0] == MODE_SINGLE:
+ return HTML(f"Ask [Cmd {context_cmd[0]} ~{lines_count}L]: ")
+ else:
+ return HTML(f"Ask [Cmd {context_cmd[0]}\u2192END ~{lines_count}L]: ")
+
+ def get_toolbar():
+ mode_labels = {MODE_RANGE: "RANGE", MODE_SINGLE: "SINGLE", MODE_LINES: "LINES"}
+ mode_label = mode_labels[context_mode[0]]
+ if context_mode[0] == MODE_LINES:
+ return HTML(f"\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {mode_label}]")
+ _, (_, preview) = get_current_block()
+ return HTML(f"\u25b6 {preview} [Tab: {mode_label}]")
+
+ try:
+ session = PromptSession(history=self.copilot_history)
+ question = session.prompt(get_prompt_text, key_bindings=bindings, bottom_toolbar=get_toolbar)
+ except KeyboardInterrupt:
+ question = ""
+
+ # Switch back to raw mode immediately so Ctrl+C during streaming doesn't break gRPC
+ tty.setraw(sys.stdin.fileno())
+
+ # IMPORTANT: Enable OPOST so rich.Live renders correctly (translates \n to \r\n).
+ # Without this, the UI repeats the panel multiple times in raw mode.
+ mode = termios.tcgetattr(sys.stdin.fileno())
+ mode[1] = mode[1] | termios.OPOST
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, mode)
+
+ if not question or not question.strip() or question.strip() == "CANCEL":
+ console.print("\n[dim]Copilot cancelled.[/dim]")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_question="CANCEL"))
+ resume_generator()
+ continue
+
+ active_buffer = get_active_buffer()
+ request_queue.put(connpy_pb2.InteractRequest(copilot_question=question, copilot_context_buffer=active_buffer))
+
+ from rich.live import Live
+ live_text = "Thinking..."
+ panel = Panel(live_text, title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan")
+ result = {}
+ cancelled = False
+
+ try:
+ with Live(panel, console=console, refresh_per_second=10) as live:
+ for chunk_res in response_iterator:
+ if chunk_res.copilot_stream_chunk:
+ if live_text == "Thinking...": live_text = ""
+ live_text += chunk_res.copilot_stream_chunk
+ live.update(Panel(Markdown(live_text), title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan"))
+ elif chunk_res.copilot_response_json:
+ result = json.loads(chunk_res.copilot_response_json)
+ break
+ except KeyboardInterrupt:
+ cancelled = True
+ console.print("\n[dim]Copilot cancelled via Ctrl+C. Disconnecting...[/dim]")
+ break
+
+ if cancelled:
+ break
+
+ if result.get("error"):
+ console.print(f"[red]Error: {result['error']}[/red]")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action="cancel"))
+ resume_generator()
+ tty.setraw(sys.stdin.fileno())
+ continue
+
+ if live_text == "Thinking..." and result.get("guide"):
+ console.print(Panel(Markdown(result["guide"]), title="[bold cyan]Copilot Guide[/bold cyan]", border_style="cyan"))
+
+ commands = result.get("commands", [])
+ risk = result.get("risk_level", "low")
+ risk_style = {"low": "green", "high": "yellow", "destructive": "red"}.get(risk, "green")
+
+ action_sent = "cancel"
+ if commands:
+ cmd_text = "\n".join(f" {i+1}. {cmd}" for i, cmd in enumerate(commands))
+ console.print(Panel(
+ cmd_text,
+ title=f"[bold {risk_style}]Suggested Commands [{risk.upper()}][/bold {risk_style}]",
+ border_style=risk_style
+ ))
+
+ try:
+ confirm_session = PromptSession()
+ confirm_bindings = KeyBindings()
+ @confirm_bindings.add('escape')
+ def _(event):
+ event.app.exit(result='n')
+
+ pt_color = "ansi" + risk_style
+ action = confirm_session.prompt(
+ HTML(f"<{pt_color}>Send commands? (y/n/e/number/range) [n]: {pt_color}>"),
+ key_bindings=confirm_bindings
+ )
+ except KeyboardInterrupt:
+ action = "n"
+
+ if not action.strip():
+ action = "n"
+
+ action_l = action.lower().strip()
+ if action_l in ('y', 'yes', 'all'):
+ action_sent = "send_all"
+ elif action_l.startswith('e'):
+ action_sent = f"edit_{action_l[1:]}" if len(action_l) > 1 else "edit_all"
+ # For remote editing, the client edits and sends back as custom action
+ edit_session = PromptSession()
+ cmds_to_edit = []
+ if action_sent.startswith("edit_") and action_sent[5:].isdigit():
+ idx = int(action_sent[5:]) - 1
+ if 0 <= idx < len(commands):
+ cmds_to_edit = [commands[idx]]
+ else:
+ cmds_to_edit = commands
+
+ if cmds_to_edit:
+ target_cmd = "\n".join(cmds_to_edit)
+ try:
+ edited_cmd = edit_session.prompt(
+ HTML("Edit commands (Alt+Enter or Esc,Enter to submit):\n"),
+ default=target_cmd,
+ multiline=True
+ )
+ if edited_cmd.strip():
+ action_sent = "custom:" + edited_cmd.strip()
+ else:
+ action_sent = "cancel"
+ except KeyboardInterrupt:
+ action_sent = "cancel"
+ elif action_l not in ('n', 'no', ''):
+ action_sent = action_l
+
+ console.print("[dim]Returning to session...[/dim]\n")
+ request_queue.put(connpy_pb2.InteractRequest(copilot_action=action_sent))
+ resume_generator()
+ tty.setraw(sys.stdin.fileno())
+ continue
+
if res.stdout_data:
os.write(sys.stdout.fileno(), res.stdout_data)
+ client_buffer_bytes.extend(res.stdout_data)
finally:
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
+ os.close(wake_r)
+ os.close(wake_w)
@MethodHook
@handle_errors
diff --git a/connpy/proto/connpy.proto b/connpy/proto/connpy.proto
index 5d30f37..84b901c 100644
--- a/connpy/proto/connpy.proto
+++ b/connpy/proto/connpy.proto
@@ -90,12 +90,22 @@ message InteractRequest {
int32 cols = 5;
int32 rows = 6;
string connection_params_json = 7;
+ // Copilot fields
+ string copilot_question = 8;
+ string copilot_action = 9;
+ string copilot_context_buffer = 10;
}
message InteractResponse {
bytes stdout_data = 1;
bool success = 2;
string error_message = 3;
+ // Copilot fields
+ bool copilot_prompt = 4;
+ string copilot_buffer_preview = 5;
+ string copilot_response_json = 6;
+ string copilot_node_info_json = 7;
+ string copilot_stream_chunk = 8;
}
message FilterRequest {
diff --git a/connpy/tunnels.py b/connpy/tunnels.py
index 4ff9308..e23fe35 100644
--- a/connpy/tunnels.py
+++ b/connpy/tunnels.py
@@ -127,6 +127,7 @@ class RemoteStream:
self.response_queue = response_queue
self.running = True
self._reader_queue = asyncio.Queue()
+ self.copilot_queue = asyncio.Queue()
self.resize_callback = None
self._loop = None
self.t = None
@@ -143,6 +144,13 @@ class RemoteStream:
if req.cols > 0 and req.rows > 0:
if self.resize_callback:
self._loop.call_soon_threadsafe(self.resize_callback, req.rows, req.cols)
+ if getattr(req, "copilot_question", ""):
+ self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, {
+ "question": req.copilot_question,
+ "context_buffer": getattr(req, "copilot_context_buffer", "")
+ })
+ if getattr(req, "copilot_action", ""):
+ self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, {"action": req.copilot_action})
if req.stdin_data:
self._loop.call_soon_threadsafe(self._reader_queue.put_nowait, req.stdin_data)
except Exception: