From d62af8b037842ee3664b63a0f5ab819aa6bee1c3 Mon Sep 17 00:00:00 2001 From: Fede Luzzi Date: Fri, 8 May 2026 18:45:42 -0300 Subject: [PATCH] copilot remoto con bugs --- connpy/ai.py | 15 + connpy/core.py | 104 +++-- connpy/grpc_layer/connpy_pb2.py | 156 +++---- connpy/grpc_layer/connpy_pb2_grpc.py | 2 +- connpy/grpc_layer/server.py | 134 +++++- connpy/grpc_layer/stubs.py | 625 ++++++++++++++++++++++++++- connpy/proto/connpy.proto | 10 + connpy/tunnels.py | 8 + 8 files changed, 932 insertions(+), 122 deletions(-) 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]: "), + 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]: "), + 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: