copilot remoto con bugs
This commit is contained in:
@@ -1231,6 +1231,18 @@ class ai:
|
|||||||
os_info = node_info.get("os", "unknown")
|
os_info = node_info.get("os", "unknown")
|
||||||
node_name = node_info.get("name", "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.
|
system_prompt = f"""Role: TERMINAL COPILOT. You assist a network engineer during a live SSH session.
|
||||||
Rules:
|
Rules:
|
||||||
1. Answer the user's question directly based on the Terminal Context.
|
1. Answer the user's question directly based on the Terminal Context.
|
||||||
@@ -1256,6 +1268,9 @@ Terminal Context:
|
|||||||
Device OS: {os_info}
|
Device OS: {os_info}
|
||||||
Node: {node_name}"""
|
Node: {node_name}"""
|
||||||
|
|
||||||
|
if vendor_reference:
|
||||||
|
system_prompt += f"\n\nVendor Command Reference:\n{vendor_reference}"
|
||||||
|
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_question}
|
{"role": "user", "content": user_question}
|
||||||
|
|||||||
+65
-15
@@ -397,7 +397,8 @@ class node:
|
|||||||
|
|
||||||
def _child_read_ready():
|
def _child_read_ready():
|
||||||
try:
|
try:
|
||||||
data = os.read(child_fd, 4096)
|
# Increase buffer to 64KB for better high-speed handling
|
||||||
|
data = os.read(child_fd, 65536)
|
||||||
if data:
|
if data:
|
||||||
child_reader_queue.put_nowait(data)
|
child_reader_queue.put_nowait(data)
|
||||||
else:
|
else:
|
||||||
@@ -422,8 +423,8 @@ class node:
|
|||||||
buffer = ""
|
buffer = ""
|
||||||
if hasattr(self, 'mylog'):
|
if hasattr(self, 'mylog'):
|
||||||
raw = self.mylog.getvalue().decode(errors='replace')
|
raw = self.mylog.getvalue().decode(errors='replace')
|
||||||
buffer = self._logclean(raw, var=True)
|
# Move heavy log cleaning to a thread
|
||||||
# Pass the full buffer to the handler so the user can adjust context size interactively
|
buffer = await asyncio.to_thread(self._logclean, raw, True)
|
||||||
|
|
||||||
# Build node info from available metadata
|
# Build node info from available metadata
|
||||||
node_info = {"name": getattr(self, 'unique', 'unknown'), "host": getattr(self, 'host', 'unknown')}
|
node_info = {"name": getattr(self, 'unique', 'unknown'), "host": getattr(self, 'host', 'unknown')}
|
||||||
@@ -455,17 +456,40 @@ class node:
|
|||||||
if not data:
|
if not data:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# 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:
|
if skip_newlines:
|
||||||
stripped = data.lstrip(b'\r\n')
|
stripped = combined_data.lstrip(b'\r\n')
|
||||||
if stripped:
|
if stripped:
|
||||||
skip_newlines = False
|
skip_newlines = False
|
||||||
data = stripped
|
combined_data = stripped
|
||||||
else:
|
else:
|
||||||
|
if has_eof: break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await local_stream.write(data)
|
await local_stream.write(combined_data)
|
||||||
if hasattr(self, 'mylog'):
|
if hasattr(self, 'mylog'):
|
||||||
self.mylog.write(data)
|
self.mylog.write(combined_data)
|
||||||
|
|
||||||
|
if has_eof:
|
||||||
|
break
|
||||||
|
|
||||||
async def keepalive_task():
|
async def keepalive_task():
|
||||||
while True:
|
while True:
|
||||||
@@ -484,16 +508,17 @@ class node:
|
|||||||
current_size = self.mylog.tell()
|
current_size = self.mylog.tell()
|
||||||
if current_size != prev_size:
|
if current_size != prev_size:
|
||||||
try:
|
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:
|
with open(self.logfile, "w") as f:
|
||||||
f.write(self._logclean(self.mylog.getvalue().decode(), True))
|
f.write(cleaned_log)
|
||||||
prev_size = current_size
|
prev_size = current_size
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# gather runs until any task completes (or we just let them run until EOF breaks them)
|
# We wait for either the user (ingress) or the child (egress) to finish
|
||||||
# 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.
|
|
||||||
tasks = [
|
tasks = [
|
||||||
asyncio.create_task(ingress_task()),
|
asyncio.create_task(ingress_task()),
|
||||||
asyncio.create_task(egress_task())
|
asyncio.create_task(egress_task())
|
||||||
@@ -502,9 +527,34 @@ class node:
|
|||||||
tasks.append(asyncio.create_task(keepalive_task()))
|
tasks.append(asyncio.create_task(keepalive_task()))
|
||||||
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
|
if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
|
||||||
tasks.append(asyncio.create_task(savelog_task()))
|
tasks.append(asyncio.create_task(savelog_task()))
|
||||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
||||||
for p in pending:
|
done, pending = await asyncio.wait(
|
||||||
p.cancel()
|
[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:
|
finally:
|
||||||
loop.remove_reader(child_fd)
|
loop.remove_reader(child_fd)
|
||||||
try:
|
try:
|
||||||
@@ -744,7 +794,7 @@ class node:
|
|||||||
bottom_toolbar=get_toolbar
|
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]")
|
console.print("\n[dim]Copilot cancelled.[/dim]")
|
||||||
os.write(child_fd, b'\x15\r')
|
os.write(child_fd, b'\x15\r')
|
||||||
return
|
return
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,7 @@
|
|||||||
import grpc
|
import grpc
|
||||||
import warnings
|
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
|
from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
|
||||||
|
|
||||||
GRPC_GENERATED_VERSION = '1.80.0'
|
GRPC_GENERATED_VERSION = '1.80.0'
|
||||||
|
|||||||
+118
-8
@@ -55,8 +55,13 @@ def handle_errors(func):
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||||
def __init__(self, config):
|
def __init__(self, config, debug=False):
|
||||||
self.service = NodeService(config)
|
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
|
@handle_errors
|
||||||
def interact_node(self, request_iterator, context):
|
def interact_node(self, request_iterator, context):
|
||||||
@@ -79,8 +84,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
|||||||
sftp = first_req.sftp
|
sftp = first_req.sftp
|
||||||
debug = first_req.debug
|
debug = first_req.debug
|
||||||
|
|
||||||
if debug:
|
if self.server_debug:
|
||||||
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node request for: [bold cyan]{unique_id}[/bold cyan]")
|
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:
|
if first_req.connection_params_json:
|
||||||
import json
|
import json
|
||||||
@@ -198,7 +203,107 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
@@ -207,14 +312,19 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
|||||||
|
|
||||||
t_loop = threading.Thread(target=run_async_loop, daemon=True)
|
t_loop = threading.Thread(target=run_async_loop, daemon=True)
|
||||||
t_loop.start()
|
t_loop.start()
|
||||||
|
def response_generator():
|
||||||
while True:
|
while True:
|
||||||
data = response_queue.get()
|
data = response_queue.get()
|
||||||
if data is None:
|
if data is None:
|
||||||
if debug:
|
if self.server_debug:
|
||||||
printer.console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
|
self.server_console.print(f"[debug][DEBUG][/debug] gRPC interact_node session closed for: [bold cyan]{unique_id}[/bold cyan]")
|
||||||
break
|
break
|
||||||
|
if isinstance(data, connpy_pb2.InteractResponse):
|
||||||
|
yield data
|
||||||
|
else:
|
||||||
yield connpy_pb2.InteractResponse(stdout_data=data)
|
yield connpy_pb2.InteractResponse(stdout_data=data)
|
||||||
|
yield from response_generator()
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def list_nodes(self, request, context):
|
def list_nodes(self, request, context):
|
||||||
f = request.filter_str if request.filter_str else None
|
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 []
|
interceptors = [LoggingInterceptor()] if debug else []
|
||||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
|
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_ProfileServiceServicer_to_server(ProfileServicer(config), server)
|
||||||
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server)
|
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server)
|
||||||
plugin_servicer = PluginServicer(config)
|
plugin_servicer = PluginServicer(config)
|
||||||
|
|||||||
+621
-4
@@ -47,9 +47,23 @@ class NodeStub:
|
|||||||
import select
|
import select
|
||||||
import tty
|
import tty
|
||||||
import termios
|
import termios
|
||||||
|
import queue
|
||||||
import os
|
import os
|
||||||
import threading
|
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():
|
def request_generator():
|
||||||
cols, rows = 80, 24
|
cols, rows = 80, 24
|
||||||
try:
|
try:
|
||||||
@@ -63,12 +77,31 @@ class NodeStub:
|
|||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
r, _, _ = select.select([sys.stdin.fileno()], [], [])
|
try:
|
||||||
if r:
|
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:
|
try:
|
||||||
data = os.read(sys.stdin.fileno(), 1024)
|
data = os.read(sys.stdin.fileno(), 1024)
|
||||||
if not data:
|
if not data:
|
||||||
break
|
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)
|
yield connpy_pb2.InteractRequest(stdin_data=data)
|
||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
@@ -103,6 +136,7 @@ class NodeStub:
|
|||||||
# Connection established on server, show success message
|
# Connection established on server, show success message
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
||||||
printer.success(conn_msg)
|
printer.success(conn_msg)
|
||||||
|
pause_stdin[0] = False
|
||||||
tty.setraw(sys.stdin.fileno())
|
tty.setraw(sys.stdin.fileno())
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -118,10 +152,285 @@ class NodeStub:
|
|||||||
# Clear screen filter is only applied before success (Phase 1).
|
# Clear screen filter is only applied before success (Phase 1).
|
||||||
# Once the user has a prompt, Ctrl+L must work normally.
|
# Once the user has a prompt, Ctrl+L must work normally.
|
||||||
for res in response_iterator:
|
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'(?<!\\)\$', '', device_prompt)
|
||||||
|
try:
|
||||||
|
prompt_re = re.compile(prompt_re_str)
|
||||||
|
except Exception:
|
||||||
|
prompt_re = re.compile(re.sub(r'(?<!\\)\$', '', default_prompt))
|
||||||
|
|
||||||
|
for i in range(1, len(cmd_byte_positions)):
|
||||||
|
chunk = raw_bytes[cmd_byte_positions[i-1]:cmd_byte_positions[i]]
|
||||||
|
cleaned = dummy_node._logclean(chunk.decode(errors='replace'), var=True)
|
||||||
|
lines = [l for l in cleaned.split('\n') if l.strip()]
|
||||||
|
preview = lines[-1].strip() if lines else ""
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
match = prompt_re.search(preview)
|
||||||
|
if match:
|
||||||
|
cmd_text = preview[match.end():].strip()
|
||||||
|
if cmd_text:
|
||||||
|
blocks.append((cmd_byte_positions[i], preview[:80]))
|
||||||
|
|
||||||
|
clean_buffer = dummy_node._logclean(raw_bytes.decode(errors='replace'), var=True)
|
||||||
|
last_line = clean_buffer.split('\n')[-1].strip() if clean_buffer.strip() else "(prompt)"
|
||||||
|
blocks.append((len(raw_bytes), last_line[:80]))
|
||||||
|
|
||||||
|
context_cmd = [1]
|
||||||
|
total_cmds = len(blocks)
|
||||||
|
total_lines = len(clean_buffer.split('\n'))
|
||||||
|
context_lines = [min(50, total_lines)]
|
||||||
|
context_mode = [0]
|
||||||
|
MODE_RANGE, MODE_SINGLE, MODE_LINES = 0, 1, 2
|
||||||
|
|
||||||
|
bindings = KeyBindings()
|
||||||
|
|
||||||
|
@bindings.add('c-up')
|
||||||
|
def _(event):
|
||||||
|
if context_mode[0] == MODE_LINES:
|
||||||
|
if context_lines[0] >= 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"<ansicyan>Ask [Ctx: {context_lines[0]}/{total_lines}L]: </ansicyan>")
|
||||||
|
|
||||||
|
lines_count = len(get_active_buffer().split('\n'))
|
||||||
|
if context_mode[0] == MODE_SINGLE:
|
||||||
|
return HTML(f"<ansicyan>Ask [Cmd {context_cmd[0]} ~{lines_count}L]: </ansicyan>")
|
||||||
|
else:
|
||||||
|
return HTML(f"<ansicyan>Ask [Cmd {context_cmd[0]}\u2192END ~{lines_count}L]: </ansicyan>")
|
||||||
|
|
||||||
|
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"<ansigray>\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {mode_label}]</ansigray>")
|
||||||
|
_, (_, preview) = get_current_block()
|
||||||
|
return HTML(f"<ansigray>\u25b6 {preview} [Tab: {mode_label}]</ansigray>")
|
||||||
|
|
||||||
|
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("<ansicyan>Edit commands (Alt+Enter or Esc,Enter to submit):\n</ansicyan>"),
|
||||||
|
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:
|
if res.stdout_data:
|
||||||
os.write(sys.stdout.fileno(), res.stdout_data)
|
os.write(sys.stdout.fileno(), res.stdout_data)
|
||||||
|
client_buffer_bytes.extend(res.stdout_data)
|
||||||
finally:
|
finally:
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
||||||
|
os.close(wake_r)
|
||||||
|
os.close(wake_w)
|
||||||
|
|
||||||
@handle_errors
|
@handle_errors
|
||||||
def connect_dynamic(self, connection_params, debug=False):
|
def connect_dynamic(self, connection_params, debug=False):
|
||||||
@@ -129,10 +438,23 @@ class NodeStub:
|
|||||||
import select
|
import select
|
||||||
import tty
|
import tty
|
||||||
import termios
|
import termios
|
||||||
|
import queue
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
|
||||||
params_json = json.dumps(connection_params)
|
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():
|
def request_generator():
|
||||||
cols, rows = 80, 24
|
cols, rows = 80, 24
|
||||||
@@ -148,12 +470,31 @@ class NodeStub:
|
|||||||
)
|
)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
r, _, _ = select.select([sys.stdin.fileno()], [], [])
|
try:
|
||||||
if r:
|
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:
|
try:
|
||||||
data = os.read(sys.stdin.fileno(), 1024)
|
data = os.read(sys.stdin.fileno(), 1024)
|
||||||
if not data:
|
if not data:
|
||||||
break
|
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)
|
yield connpy_pb2.InteractRequest(stdin_data=data)
|
||||||
except OSError:
|
except OSError:
|
||||||
break
|
break
|
||||||
@@ -189,6 +530,7 @@ class NodeStub:
|
|||||||
# Connection established on server, show success message
|
# Connection established on server, show success message
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
||||||
printer.success(conn_msg)
|
printer.success(conn_msg)
|
||||||
|
pause_stdin[0] = False
|
||||||
tty.setraw(sys.stdin.fileno())
|
tty.setraw(sys.stdin.fileno())
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -204,10 +546,285 @@ class NodeStub:
|
|||||||
# Clear screen filter is only applied before success (Phase 1).
|
# Clear screen filter is only applied before success (Phase 1).
|
||||||
# Once the user has a prompt, Ctrl+L must work normally.
|
# Once the user has a prompt, Ctrl+L must work normally.
|
||||||
for res in response_iterator:
|
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'(?<!\\)\$', '', device_prompt)
|
||||||
|
try:
|
||||||
|
prompt_re = re.compile(prompt_re_str)
|
||||||
|
except Exception:
|
||||||
|
prompt_re = re.compile(re.sub(r'(?<!\\)\$', '', default_prompt))
|
||||||
|
|
||||||
|
for i in range(1, len(cmd_byte_positions)):
|
||||||
|
chunk = raw_bytes[cmd_byte_positions[i-1]:cmd_byte_positions[i]]
|
||||||
|
cleaned = dummy_node._logclean(chunk.decode(errors='replace'), var=True)
|
||||||
|
lines = [l for l in cleaned.split('\n') if l.strip()]
|
||||||
|
preview = lines[-1].strip() if lines else ""
|
||||||
|
|
||||||
|
if preview:
|
||||||
|
match = prompt_re.search(preview)
|
||||||
|
if match:
|
||||||
|
cmd_text = preview[match.end():].strip()
|
||||||
|
if cmd_text:
|
||||||
|
blocks.append((cmd_byte_positions[i], preview[:80]))
|
||||||
|
|
||||||
|
clean_buffer = dummy_node._logclean(raw_bytes.decode(errors='replace'), var=True)
|
||||||
|
last_line = clean_buffer.split('\n')[-1].strip() if clean_buffer.strip() else "(prompt)"
|
||||||
|
blocks.append((len(raw_bytes), last_line[:80]))
|
||||||
|
|
||||||
|
context_cmd = [1]
|
||||||
|
total_cmds = len(blocks)
|
||||||
|
total_lines = len(clean_buffer.split('\n'))
|
||||||
|
context_lines = [min(50, total_lines)]
|
||||||
|
context_mode = [0]
|
||||||
|
MODE_RANGE, MODE_SINGLE, MODE_LINES = 0, 1, 2
|
||||||
|
|
||||||
|
bindings = KeyBindings()
|
||||||
|
|
||||||
|
@bindings.add('c-up')
|
||||||
|
def _(event):
|
||||||
|
if context_mode[0] == MODE_LINES:
|
||||||
|
if context_lines[0] >= 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"<ansicyan>Ask [Ctx: {context_lines[0]}/{total_lines}L]: </ansicyan>")
|
||||||
|
|
||||||
|
lines_count = len(get_active_buffer().split('\n'))
|
||||||
|
if context_mode[0] == MODE_SINGLE:
|
||||||
|
return HTML(f"<ansicyan>Ask [Cmd {context_cmd[0]} ~{lines_count}L]: </ansicyan>")
|
||||||
|
else:
|
||||||
|
return HTML(f"<ansicyan>Ask [Cmd {context_cmd[0]}\u2192END ~{lines_count}L]: </ansicyan>")
|
||||||
|
|
||||||
|
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"<ansigray>\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {mode_label}]</ansigray>")
|
||||||
|
_, (_, preview) = get_current_block()
|
||||||
|
return HTML(f"<ansigray>\u25b6 {preview} [Tab: {mode_label}]</ansigray>")
|
||||||
|
|
||||||
|
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("<ansicyan>Edit commands (Alt+Enter or Esc,Enter to submit):\n</ansicyan>"),
|
||||||
|
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:
|
if res.stdout_data:
|
||||||
os.write(sys.stdout.fileno(), res.stdout_data)
|
os.write(sys.stdout.fileno(), res.stdout_data)
|
||||||
|
client_buffer_bytes.extend(res.stdout_data)
|
||||||
finally:
|
finally:
|
||||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty)
|
||||||
|
os.close(wake_r)
|
||||||
|
os.close(wake_w)
|
||||||
|
|
||||||
@MethodHook
|
@MethodHook
|
||||||
@handle_errors
|
@handle_errors
|
||||||
|
|||||||
@@ -90,12 +90,22 @@ message InteractRequest {
|
|||||||
int32 cols = 5;
|
int32 cols = 5;
|
||||||
int32 rows = 6;
|
int32 rows = 6;
|
||||||
string connection_params_json = 7;
|
string connection_params_json = 7;
|
||||||
|
// Copilot fields
|
||||||
|
string copilot_question = 8;
|
||||||
|
string copilot_action = 9;
|
||||||
|
string copilot_context_buffer = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message InteractResponse {
|
message InteractResponse {
|
||||||
bytes stdout_data = 1;
|
bytes stdout_data = 1;
|
||||||
bool success = 2;
|
bool success = 2;
|
||||||
string error_message = 3;
|
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 {
|
message FilterRequest {
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class RemoteStream:
|
|||||||
self.response_queue = response_queue
|
self.response_queue = response_queue
|
||||||
self.running = True
|
self.running = True
|
||||||
self._reader_queue = asyncio.Queue()
|
self._reader_queue = asyncio.Queue()
|
||||||
|
self.copilot_queue = asyncio.Queue()
|
||||||
self.resize_callback = None
|
self.resize_callback = None
|
||||||
self._loop = None
|
self._loop = None
|
||||||
self.t = None
|
self.t = None
|
||||||
@@ -143,6 +144,13 @@ class RemoteStream:
|
|||||||
if req.cols > 0 and req.rows > 0:
|
if req.cols > 0 and req.rows > 0:
|
||||||
if self.resize_callback:
|
if self.resize_callback:
|
||||||
self._loop.call_soon_threadsafe(self.resize_callback, req.rows, req.cols)
|
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:
|
if req.stdin_data:
|
||||||
self._loop.call_soon_threadsafe(self._reader_queue.put_nowait, req.stdin_data)
|
self._loop.call_soon_threadsafe(self._reader_queue.put_nowait, req.stdin_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user