import os import re import sys import time import asyncio import fcntl import termios import tty from typing import Any, Dict, List, Optional, Callable from textwrap import dedent 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.filters import has_completions from prompt_toolkit.formatted_text import HTML from prompt_toolkit.history import InMemoryHistory from ..printer import connpy_theme from connpy.utils import log_cleaner from ..services.ai_service import AIService class CopilotInterface: def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None): self.config = config self.history = history or InMemoryHistory() self.pt_input = pt_input self.pt_output = pt_output self.ai_service = AIService(config) self.session_state = session_state if session_state is not None else { 'persona': 'engineer', 'trust_mode': False, 'memories': [], 'os': None, 'prompt': None } if rich_file: self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file) else: self.console = Console(theme=connpy_theme) self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2 def _get_theme_color(self, style_name: str, fallback: str = "white") -> str: """Extract Hex or ANSI color name from the active rich theme.""" try: style = connpy_theme.styles.get(style_name) if style and style.color: # If it's a standard color like 'green', Rich might return its hex triplet if style.color.is_default: return fallback return style.color.triplet.hex if style.color.triplet else style.color.name except: pass return fallback async def run_session(self, raw_bytes: bytes, node_info: dict, on_ai_call: Callable, cmd_byte_positions: List[tuple] = None, blocks: List[tuple] = None): """ Runs the interactive Copilot session. on_ai_call: async function(active_buffer, question) -> result_dict """ from rich.rule import Rule try: # Prepare UI state buffer = log_cleaner(raw_bytes.decode(errors='replace')) # Use pre-calculated blocks if provided (remote mode), otherwise calculate locally (local mode) if blocks is None: last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)" blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line) state = { 'context_cmd': 1, 'total_cmds': len(blocks), 'total_lines': len(buffer.split('\n')), 'context_lines': min(50, len(buffer.split('\n'))), 'context_mode': self.mode_range, 'cancelled': False, 'toolbar_msg': '', 'msg_expiry': 0 } # 1. Visual Separation self.console.print("") # Real line break self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan")) self.console.print(Panel( "[dim]Type your question. Enter to send, Escape/Ctrl+C to cancel. Type / for commands.\n" "Tab to change context mode. Ctrl+\u2191/\u2193 to adjust context. \u2191\u2193 for question history.[/dim]", border_style="cyan" )) self.console.print("\n") # Small space before the copilot prompt bindings = KeyBindings() @bindings.add('c-up') def _(event): if state['context_mode'] == self.mode_lines: state['context_lines'] = min(state['context_lines'] + 50, state['total_lines']) else: state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds']) event.app.invalidate() @bindings.add('c-down') def _(event): if state['context_mode'] == self.mode_lines: state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines'])) else: state['context_cmd'] = max(state['context_cmd'] - 1, 1) event.app.invalidate() @bindings.add('tab') def _(event): buf = event.current_buffer # If typing a slash command (no spaces yet), use tab to autocomplete inline if buf.text.startswith('/') and ' ' not in buf.text: buf.complete_next() else: state['context_mode'] = (state['context_mode'] + 1) % 3 event.app.invalidate() @bindings.add('escape', eager=True) @bindings.add('c-c') def _(event): state['cancelled'] = True event.app.exit(result='') # Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline @bindings.add('enter', filter=~has_completions) def _(event): event.current_buffer.validate_and_handle() @bindings.add('c-j') @bindings.add('escape', 'enter') def _(event): event.current_buffer.insert_text('\n') def get_active_buffer(): if state['context_mode'] == self.mode_lines: return '\n'.join(buffer.split('\n')[-state['context_lines']:]) idx = max(0, state['total_cmds'] - state['context_cmd']) start, end, preview = blocks[idx] if state['context_mode'] == self.mode_single: active_raw = raw_bytes[start:end] else: # Concat only the bytes of valid blocks to skip intermediate empty/cancelled prompt noise active_raw = b"".join(raw_bytes[b[0]:b[1]] for b in blocks[idx:]) return preview + "\n" + log_cleaner(active_raw.decode(errors='replace')) def get_prompt_text(): import html # Always use user_prompt color for the Ask prompt color = self._get_theme_color("user_prompt", "cyan") if state['context_mode'] == self.mode_lines: text = html.escape(f"Ask [Ctx: {state['context_lines']}/{state['total_lines']}L]: ") return HTML(f'') active = get_active_buffer() lines_count = len(active.split('\n')) mode_str = {self.mode_range: "Range", self.mode_single: "Cmd"}[state['context_mode']] text = html.escape(f"Ask [{mode_str} {state['context_cmd']} ~{lines_count}L]: ") return HTML(f'') from prompt_toolkit.application.current import get_app def get_toolbar(): import html app = get_app() c_warning = self._get_theme_color("warning", "yellow") if app and app.current_buffer: text = app.current_buffer.text # Only show command help if typing the first command and there are no spaces if text.startswith('/') and ' ' not in text: commands = ['/os', '/prompt', '/architect', '/engineer', '/trust', '/untrust', '/memorize', '/clear'] matches = [c for c in commands if c.startswith(text.lower())] if matches: m_text = html.escape(f"Available: {' '.join(matches)}") return HTML(f'' + " " * 20) m_label = {self.mode_range: "RANGE", self.mode_single: "SINGLE", self.mode_lines: "LINES"}[state['context_mode']] if state['context_mode'] == self.mode_lines: base_str = f'\u25b6 Ctrl+\u2191/\u2193 adjusts by 50 lines [Tab: {m_label}]' else: idx = max(0, state['total_cmds'] - state['context_cmd']) def clean_preview(text): # Clean newlines and the initial prompt (all up to #, > or $) to leave only the command original = text.strip().replace('\r', '').replace('\n', ' ') cleaned = re.sub(r'^.*?[#>\$]\s*', '', original) # If cleaning the prompt leaves us with an empty string (e.g. it was just "iol#"), return the original return cleaned if cleaned else original if state['context_mode'] == self.mode_range: range_blocks = blocks[idx:] # If there is more than one block, the last one is always the empty/current prompt. We omit it visually. if len(range_blocks) > 1: range_blocks = range_blocks[:-1] # Clean and truncate very long commands so they don't break the UI previews = [] for b in range_blocks: p = clean_preview(b[2]) if p: # Truncar comandos individuales largos if len(p) > 25: p = p[:22] + "..." previews.append(p) if not previews: desc = clean_preview(blocks[idx][2]) elif len(previews) <= 3: desc = " + ".join(previews) else: desc = f"{previews[0]} + {previews[1]} + {previews[2]} ... (+{len(previews)-3})" else: # Modo SINGLE original desc = clean_preview(blocks[idx][2]) base_str = f'\u25b6 {desc} [Tab: {m_label}]' # Wrap base_str in a style to maintain consistency and avoid glitches # The fg color will be inherited from bottom-toolbar global style if not specified here base_html = f'{html.escape(base_str)}' res_html = base_html if state.get('toolbar_msg'): if time.time() < state.get('msg_expiry', 0): msg = html.escape(state['toolbar_msg']) res_html = f' | ' + base_html else: state['toolbar_msg'] = '' # Pad with spaces to ensure the line is cleared when the message disappears return HTML(res_html + " " * 20) from prompt_toolkit.completion import Completer, Completion class SlashCommandCompleter(Completer): def get_completions(self, document, complete_event): text = document.text_before_cursor if text.startswith('/'): parts = text.split() # Only autocomplete the first word if len(parts) <= 1 or (len(parts) == 1 and not text.endswith(' ')): cmd_part = parts[0] if parts else text commands = [ ('/os', 'Set device OS (e.g. cisco_ios)'), ('/prompt', 'Override prompt regex'), ('/architect', 'Switch to Architect persona'), ('/engineer', 'Switch to Engineer persona'), ('/trust', 'Enable auto-execute'), ('/untrust', 'Disable auto-execute'), ('/memorize', 'Add fact to memory'), ('/clear', 'Clear memory') ] for cmd, desc in commands: if cmd.startswith(cmd_part.lower()): yield Completion(cmd, start_position=-len(cmd_part), display_meta=desc) copilot_completer = SlashCommandCompleter() while True: # 2. Ask question from prompt_toolkit.styles import Style c_contrast = self._get_theme_color("contrast", "gray") ui_style = Style.from_dict({ 'bottom-toolbar': f'fg:{c_contrast}', }) session = PromptSession( history=self.history, input=self.pt_input, output=self.pt_output, completer=copilot_completer, reserve_space_for_menu=0, style=ui_style ) try: # We use an internal try/finally to ensure that if something fails in prompt_async, # we don't leave the terminal in a strange state. question = await session.prompt_async( get_prompt_text, key_bindings=bindings, bottom_toolbar=get_toolbar, multiline=True ) except (KeyboardInterrupt, EOFError): state['cancelled'] = True question = "" if state['cancelled'] or not question.strip() or question.strip().lower() in ['cancel', 'exit', 'quit']: return "cancel", None, None # 3. Process Input via AIService directive = self.ai_service.process_copilot_input(question, self.session_state) if directive["action"] == "state_update": msg = directive['message'] state['toolbar_msg'] = msg state['msg_expiry'] = time.time() + 3 # 3 seconds timeout async def delayed_refresh(): await asyncio.sleep(3.1) # Only invalidate if the message hasn't been replaced by a newer one if state.get('toolbar_msg') == msg: state['toolbar_msg'] = '' # Explicitly clear try: from prompt_toolkit.application.current import get_app app = get_app() if app: app.invalidate() except: pass asyncio.create_task(delayed_refresh()) # Move the cursor up and clean the line so the new prompt replaces the previous one sys.stdout.write('\x1b[1A\x1b[2K') sys.stdout.flush() continue else: # Clean the toolbar message when a real question is asked state['toolbar_msg'] = '' clean_question = directive.get("clean_prompt", question) overrides = directive.get("overrides", {}) # Merge node_info with session_state and overrides merged_node_info = node_info.copy() if self.session_state['os']: merged_node_info['os'] = self.session_state['os'] if self.session_state['prompt']: merged_node_info['prompt'] = self.session_state['prompt'] merged_node_info['persona'] = self.session_state['persona'] merged_node_info['trust'] = self.session_state['trust_mode'] merged_node_info['memories'] = list(self.session_state['memories']) for k, v in overrides.items(): merged_node_info[k] = v # Enrich question past = self.history.get_strings() if len(past) > 1: clean_past = [q for q in past[-6:-1] if not q.startswith('/')] if clean_past: history_text = "\n".join(f"- {q}" for q in clean_past) clean_question = f"Previous questions:\n{history_text}\n\nCurrent Question:\n{clean_question}" # 3. AI Execution # Use persona from overrides (one-shot) or from session state active_persona = merged_node_info.get('persona', self.session_state.get('persona', 'engineer')) persona_color = self._get_theme_color(active_persona, fallback="cyan") persona_title = "Network Architect" if active_persona == "architect" else "Network Engineer" active_buffer = get_active_buffer() live_text = "" first_chunk = True from rich.rule import Rule from rich.status import Status from connpy.printer import IncrementalMarkdownParser md_parser = IncrementalMarkdownParser(console=self.console) status_spinner = Status( f"[bold {persona_color}]{persona_title}:[/bold {persona_color}] [dim]Thinking...[/dim]", console=self.console, spinner="dots" ) status_spinner.start() def on_chunk(text): nonlocal live_text, first_chunk if first_chunk: status_spinner.stop() # Print header rule before first chunk arrives self.console.print(Rule( f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", style=persona_color )) first_chunk = False live_text += text md_parser.feed(text) # Check for interruption during AI call ai_task = asyncio.create_task(on_ai_call(active_buffer, clean_question, on_chunk, merged_node_info)) try: while not ai_task.done(): await asyncio.sleep(0.05) result = await ai_task except asyncio.CancelledError: status_spinner.stop() return "cancel", None, None # Ensure spinner is stopped if no chunks arrived if first_chunk: status_spinner.stop() # Close the streamed output with a Rule if not first_chunk: md_parser.flush() self.console.print(Rule(style=persona_color)) if not result or result.get("error"): if first_chunk and result and result.get("error"): self.console.print(f"[red]Error: {result['error']}[/red]") return "cancel", None, None # If no chunks were streamed but we have a guide, print it as a panel if first_chunk and result and result.get("guide"): self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color)) commands = result.get("commands", []) if not commands: self.console.print("") return "continue", None, None risk = result.get("risk_level", "low") risk_style = {"low": "success", "high": "warning", "destructive": "error"}.get(risk, "success") style_color = self._get_theme_color(risk_style, fallback="green") cmd_text = "\n".join(f" {i+1}. {c}" for i, c in enumerate(commands)) # Explicitly use 'bold style_color' for both TITLE and BORDER to ensure maximum consistency self.console.print(Panel(cmd_text, title=f"[bold {style_color}]Suggested Commands [{risk.upper()}][/bold {style_color}]", border_style=f"bold {style_color}")) if merged_node_info.get('trust', False) and risk != "destructive": self.console.print(f"[dim]⚙️ Auto-executing (Trust Mode)[/dim]") return "send_all", commands, None confirm_session = PromptSession(input=self.pt_input, output=self.pt_output) c_bindings = KeyBindings() @c_bindings.add('escape', eager=True) @c_bindings.add('c-c') def _(ev): ev.app.exit(result='n') import html try: p_text = html.escape(f"Send? (y/n/e/range) [n]: ") # Use the EXACT same style_color and force bold="true" for Prompt-Toolkit action = await confirm_session.prompt_async(HTML(f''), key_bindings=c_bindings) except (KeyboardInterrupt, EOFError): self.console.print("") return "continue", None, None def parse_indices(text, max_len): """Helper to parse '1-3, 5, 7' into [0, 1, 2, 4, 6].""" indices = [] # Replace commas with spaces and split parts = text.replace(',', ' ').split() for part in parts: if '-' in part: try: start, end = map(int, part.split('-')) # Ensure inclusive and 0-indexed indices.extend(range(start-1, end)) except: continue elif part.isdigit(): indices.append(int(part)-1) # Filter valid indices and remove duplicates return [i for i in sorted(set(indices)) if 0 <= i < max_len] action_l = (action or "n").lower().strip() if action_l in ('y', 'yes', 'all'): return "send_all", commands, None # Check for numeric selection (e.g., "1, 2-4") if re.match(r'^[0-9,\-\s]+$', action_l): selected_idxs = parse_indices(action_l, len(commands)) if selected_idxs: return "send_all", [commands[i] for i in selected_idxs], None elif action_l.startswith('e'): # Check if it's a selective edit like 'e1-2' selection_str = action_l[1:].strip() if selection_str: idxs = parse_indices(selection_str, len(commands)) cmds_to_edit = [commands[i] for i in idxs] if idxs else commands else: cmds_to_edit = commands target = "\n".join(cmds_to_edit) e_bindings = KeyBindings() @e_bindings.add('c-j') def _(ev): ev.app.exit(result=ev.app.current_buffer.text) @e_bindings.add('escape', 'enter') def _(ev): ev.app.exit(result=ev.app.current_buffer.text) @e_bindings.add('escape') def _(ev): ev.app.exit(result='') c_edit = self._get_theme_color("user_prompt", "cyan") import html e_text = html.escape("Edit (Ctrl+Enter or Esc+Enter to submit):\n") try: edited = await confirm_session.prompt_async( HTML(f''), default=target, multiline=True, key_bindings=e_bindings ) except (KeyboardInterrupt, EOFError): self.console.print("") return "continue", None, None if edited and edited.strip(): # Split by lines to ensure core.py applies delay between each command lines = [l.strip() for l in edited.split('\n') if l.strip()] return "custom", None, lines self.console.print("") return "continue", None, None return "cancel", None, None finally: state['cancelled'] = True