approval and prompt settle
- Add /mission and /cancel slash commands in AIService
- Implement autonomous multi-step execution loop with scratchpad in
CopilotInterface
- Add dynamic prompt settle detection in core.py after command injection
- Add step limits with summary fallback and interactive feedback on pause
- Add unit test coverage for mission lifecycle and state management
684 lines
35 KiB
Python
684 lines
35 KiB
Python
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 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
|
|
|
|
class CopilotInterface:
|
|
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
|
|
from ..services.ai_service import AIService
|
|
self.config = config
|
|
self.history = history or InMemoryHistory()
|
|
self.pt_input = pt_input
|
|
self.pt_output = pt_output
|
|
self.rich_file = rich_file
|
|
self.ai_service = AIService(config)
|
|
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
|
|
|
|
self.session_state = session_state if session_state is not None else {}
|
|
self.session_state.setdefault('persona', 'engineer')
|
|
self.session_state.setdefault('trust_mode', False)
|
|
self.session_state.setdefault('memories', [])
|
|
self.session_state.setdefault('copilot_chat_history', [])
|
|
self.session_state.setdefault('os', None)
|
|
self.session_state.setdefault('prompt', None)
|
|
self.session_state.setdefault('context_mode', self.mode_range)
|
|
self.session_state.setdefault('context_cmd', 1)
|
|
self.session_state.setdefault('context_lines', 50)
|
|
self.session_state.setdefault('last_total_cmds', None)
|
|
self.session_state.setdefault('last_total_lines', None)
|
|
|
|
if rich_file:
|
|
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
|
|
else:
|
|
self.console = Console(theme=connpy_theme)
|
|
|
|
def _sync_session_context(self, state: dict):
|
|
"""Persist current context mode, depth, total commands, and total lines into session_state."""
|
|
self.session_state['context_mode'] = state['context_mode']
|
|
self.session_state['context_cmd'] = state['context_cmd']
|
|
self.session_state['context_lines'] = state['context_lines']
|
|
self.session_state['last_total_cmds'] = state['total_cmds']
|
|
self.session_state['last_total_lines'] = state['total_lines']
|
|
|
|
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)
|
|
|
|
total_cmds = len(blocks)
|
|
total_lines = len(buffer.split('\n'))
|
|
|
|
saved_mode = self.session_state.get('context_mode', self.mode_range)
|
|
saved_cmd = self.session_state.get('context_cmd', 1)
|
|
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
|
|
last_total_cmds = self.session_state.get('last_total_cmds', None)
|
|
last_total_lines = self.session_state.get('last_total_lines', None)
|
|
|
|
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
|
|
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
|
|
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
|
|
|
|
if is_range or is_single:
|
|
is_mission = self.session_state.get('mission', {}).get('active', False)
|
|
if last_total_cmds is not None and total_cmds > last_total_cmds and (saved_cmd > 1 or is_mission):
|
|
new_cmds = total_cmds - last_total_cmds
|
|
initial_cmd = saved_cmd + new_cmds
|
|
else:
|
|
initial_cmd = saved_cmd
|
|
initial_lines = saved_lines
|
|
elif is_lines:
|
|
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
|
|
new_lines = total_lines - last_total_lines
|
|
initial_lines = saved_lines + new_lines
|
|
else:
|
|
initial_lines = saved_lines
|
|
initial_cmd = saved_cmd
|
|
else:
|
|
initial_cmd = saved_cmd
|
|
initial_lines = saved_lines
|
|
|
|
state = {
|
|
'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
|
|
'total_cmds': total_cmds,
|
|
'total_lines': total_lines,
|
|
'context_lines': min(max(1, initial_lines), max(1, total_lines)),
|
|
'context_mode': saved_mode,
|
|
'cancelled': False,
|
|
'toolbar_msg': '',
|
|
'msg_expiry': 0
|
|
}
|
|
self.session_state['context_mode'] = saved_mode
|
|
self.session_state['context_cmd'] = max(1, initial_cmd)
|
|
self.session_state['context_lines'] = max(1, initial_lines)
|
|
self.session_state['last_total_cmds'] = total_cmds
|
|
self.session_state['last_total_lines'] = total_lines
|
|
|
|
# 1. Visual Separation (Only show help banner on initial entry)
|
|
self.console.print("") # Real line break
|
|
self.console.print(Rule(title="[bold cyan] AI TERMINAL COPILOT [/bold cyan]", style="cyan"))
|
|
if not self.session_state.get('banner_shown', False):
|
|
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.session_state['banner_shown'] = True
|
|
self.console.print("") # 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'])
|
|
self._sync_session_context(state)
|
|
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)
|
|
self._sync_session_context(state)
|
|
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
|
|
self._sync_session_context(state)
|
|
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'<style fg="{color}">{text}</style>')
|
|
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'<style fg="{color}">{text}</style>')
|
|
|
|
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', '/mission', '/cancel']
|
|
matches = [c for c in commands if c.startswith(text.lower())]
|
|
if matches:
|
|
m_text = html.escape(f"Available: {' '.join(matches)}")
|
|
return HTML(f'<style fg="{c_warning}">{m_text}</style>' + " " * 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 [Tab: {m_label}] {state["context_lines"]}/{state["total_lines"]}L (Ctrl+\u2191/\u2193 adjusts lines)'
|
|
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].rstrip(' .,-_') + "..."
|
|
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 [Tab: {m_label}] {desc}'
|
|
|
|
# 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'<span>{html.escape(base_str)}</span>'
|
|
|
|
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'<style fg="{c_warning}">⚙️ {msg}</style> | ' + 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'),
|
|
('/mission', 'Start autonomous mission'),
|
|
('/cancel', 'Cancel active mission')
|
|
]
|
|
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()
|
|
|
|
def _finalize_mission(reason="completed", final_guide=""):
|
|
mission = self.session_state.get('mission', {})
|
|
if not mission or not mission.get('active', False):
|
|
return
|
|
mission['active'] = False
|
|
if reason == "completed":
|
|
self.console.print("\n[bold green]🎉 Mission Completed[/bold green]")
|
|
elif reason == "user_cancelled":
|
|
self.console.print("\n[yellow]Mission cancelled by user.[/yellow]")
|
|
elif reason == "limit_reached":
|
|
self.console.print("\n[yellow]Mission step limit reached.[/yellow]")
|
|
|
|
final_notes = "\n".join(mission.get('scratchpad_notes', []))
|
|
guide = final_guide or mission.get('last_guide', '')
|
|
asst_msg = f"Notes: {final_notes}\nGuide: {guide}" if final_notes else guide
|
|
hist = self.session_state.setdefault("copilot_chat_history", [])
|
|
hist.append({"role": "user", "content": f"/mission {mission.get('goal', '')}"})
|
|
hist.append({"role": "assistant", "content": asst_msg})
|
|
self.session_state["copilot_chat_history"] = hist[-10:]
|
|
|
|
while True:
|
|
overrides = {}
|
|
# Check for active mission auto-looping
|
|
mission = self.session_state.get('mission', {})
|
|
is_mission = mission.get('active', False) and not mission.get('paused', False)
|
|
|
|
if is_mission:
|
|
# Force mode_range for mission mode
|
|
state['context_mode'] = self.mode_range
|
|
self.session_state['context_mode'] = self.mode_range
|
|
|
|
if mission.get('start_block_idx') is None:
|
|
mission['start_block_idx'] = state['total_cmds']
|
|
|
|
start_idx = mission.get('start_block_idx', state['total_cmds'])
|
|
cmds_since_start = max(1, (state['total_cmds'] - start_idx) + 1)
|
|
state['context_cmd'] = max(state.get('context_cmd', 1), cmds_since_start)
|
|
self.session_state['context_cmd'] = state['context_cmd']
|
|
|
|
step = mission.get('step', 1)
|
|
max_steps = mission.get('max_steps', 10)
|
|
|
|
if step > max_steps:
|
|
ext_session = PromptSession(input=self.pt_input, output=self.pt_output)
|
|
c_warn = self._get_theme_color("warning", "yellow")
|
|
import html
|
|
p_warn = html.escape(f"[Mission Limit Reached ({max_steps} steps)] Extend mission for 10 more steps? (y/n) [y]: ")
|
|
try:
|
|
ext_ans = await ext_session.prompt_async(HTML(f'<style fg="{c_warn}" bold="true">{p_warn}</style>'))
|
|
except (KeyboardInterrupt, EOFError):
|
|
ext_ans = 'n'
|
|
|
|
if (ext_ans or 'y').lower().strip() in ('y', 'yes'):
|
|
mission['max_steps'] += 10
|
|
else:
|
|
goal = mission.get('goal', '')
|
|
question = f"[MISSION SUMMARY]: Step limit reached ({max_steps} steps). Provide a concise summary of all findings and current status for: {goal}"
|
|
clean_question = question
|
|
mission['active'] = False
|
|
is_mission = False
|
|
self.console.print(f"\n[bold cyan]🤖 Generating Final Mission Summary ({max_steps} steps reached)...[/bold cyan]")
|
|
|
|
if is_mission:
|
|
goal = mission.get('goal', '')
|
|
step = mission.get('step', 1)
|
|
question = f"[MISSION STEP {step}]: Continue analysis of: {goal}"
|
|
|
|
scratchpad = mission.get('scratchpad_notes', [])
|
|
if scratchpad:
|
|
notes_text = "\n".join(f"- {n}" for n in scratchpad)
|
|
question += f"\n\nPast Mission Notes:\n{notes_text}"
|
|
|
|
clean_question = question
|
|
self.console.print(f"\n[bold cyan]🤖 Executing Mission Step {step}: {goal}[/bold cyan]")
|
|
elif not is_mission and 'clean_question' in locals() and clean_question.startswith("[MISSION SUMMARY]"):
|
|
# Pass-through to AI execution for generating final summary
|
|
pass
|
|
else:
|
|
# 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:
|
|
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']:
|
|
_finalize_mission("user_cancelled")
|
|
return "cancel", None, None
|
|
|
|
# 3. Process Input via AIService
|
|
directive = self.ai_service.process_copilot_input(question, self.session_state)
|
|
|
|
if directive["action"] == "mission_start":
|
|
mission = self.session_state.get('mission', {})
|
|
mission['start_block_idx'] = state['total_cmds']
|
|
state['context_mode'] = self.mode_range
|
|
self.session_state['context_mode'] = self.mode_range
|
|
state['context_cmd'] = 1
|
|
self.session_state['context_cmd'] = 1
|
|
clean_question = f"[MISSION STEP 1]: {directive['clean_prompt']}"
|
|
overrides = directive.get("overrides", {})
|
|
self.console.print(f"\n[bold cyan]🤖 Starting Mission: {directive['clean_prompt']}[/bold cyan]")
|
|
elif directive["action"] == "mission_cancel":
|
|
_finalize_mission("user_cancelled")
|
|
state['toolbar_msg'] = 'Mission cancelled'
|
|
continue
|
|
elif 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)
|
|
if state.get('toolbar_msg') == msg:
|
|
state['toolbar_msg'] = ''
|
|
try:
|
|
from prompt_toolkit.application.current import get_app
|
|
app = get_app()
|
|
if app: app.invalidate()
|
|
except: pass
|
|
asyncio.create_task(delayed_refresh())
|
|
|
|
sys.stdout.write('\x1b[1A\x1b[2K')
|
|
sys.stdout.flush()
|
|
continue
|
|
else:
|
|
state['toolbar_msg'] = ''
|
|
if mission.get('active', False):
|
|
mission['paused'] = False
|
|
step = mission.get('step', 1)
|
|
goal = mission.get('goal', '')
|
|
clean_question = f"[MISSION FEEDBACK (Step {step})]: User provided guidance: {question}. Continue the mission for goal: {goal}"
|
|
self.console.print(f"\n[bold cyan]🤖 Continuing Mission with User Feedback: {question}[/bold cyan]")
|
|
overrides = directive.get("overrides", {})
|
|
else:
|
|
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'])
|
|
merged_node_info['chat_history'] = list(self.session_state.get('copilot_chat_history', []))
|
|
|
|
for k, v in overrides.items():
|
|
merged_node_info[k] = v
|
|
|
|
# 3. AI Execution
|
|
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()
|
|
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)
|
|
|
|
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()
|
|
_finalize_mission("user_cancelled")
|
|
return "cancel", None, None
|
|
|
|
if first_chunk:
|
|
status_spinner.stop()
|
|
|
|
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]")
|
|
_finalize_mission("user_cancelled")
|
|
return "cancel", None, None
|
|
|
|
if first_chunk and result and result.get("guide"):
|
|
from rich.markdown import Markdown
|
|
self.console.print(Panel(Markdown(result["guide"]), title=f"[bold {persona_color}]{persona_title}[/bold {persona_color}]", border_style=persona_color))
|
|
|
|
# Update copilot_chat_history or mission scratchpad
|
|
if result and not result.get("error"):
|
|
guide = result.get("guide", "")
|
|
notes = result.get("notes", "")
|
|
mission = self.session_state.get('mission', {})
|
|
if mission.get('active', False):
|
|
if notes:
|
|
mission.setdefault('scratchpad_notes', []).append(notes)
|
|
if guide:
|
|
mission['last_guide'] = guide
|
|
|
|
if guide or notes:
|
|
asst_msg = f"Notes: {notes}\nGuide: {guide}" if notes else guide
|
|
if not asst_msg and guide: asst_msg = guide
|
|
hist = self.session_state.setdefault("copilot_chat_history", [])
|
|
hist.append({"role": "user", "content": clean_question})
|
|
hist.append({"role": "assistant", "content": asst_msg})
|
|
self.session_state["copilot_chat_history"] = hist[-10:]
|
|
commands = result.get("commands", [])
|
|
mission = self.session_state.get('mission', {})
|
|
if not commands:
|
|
if mission.get('active', False):
|
|
_finalize_mission("completed", result.get("guide", ""))
|
|
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))
|
|
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]")
|
|
if mission.get('active', False):
|
|
mission['step'] = mission.get('step', 1) + 1
|
|
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]: ")
|
|
action = await confirm_session.prompt_async(HTML(f'<style fg="{style_color}" bold="true">{p_text}</style>'), key_bindings=c_bindings)
|
|
except (KeyboardInterrupt, EOFError):
|
|
_finalize_mission("user_cancelled", result.get("guide", ""))
|
|
self.console.print("")
|
|
return "continue", None, None
|
|
|
|
def parse_indices(text, max_len):
|
|
indices = []
|
|
parts = text.replace(',', ' ').split()
|
|
for part in parts:
|
|
if '-' in part:
|
|
try:
|
|
start, end = map(int, part.split('-'))
|
|
indices.extend(range(start-1, end))
|
|
except: continue
|
|
elif part.isdigit():
|
|
indices.append(int(part)-1)
|
|
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'):
|
|
if mission.get('active', False):
|
|
mission['step'] = mission.get('step', 1) + 1
|
|
return "send_all", commands, None
|
|
|
|
if re.match(r'^[0-9,\-\s]+$', action_l):
|
|
selected_idxs = parse_indices(action_l, len(commands))
|
|
if selected_idxs:
|
|
if mission.get('active', False):
|
|
mission['step'] = mission.get('step', 1) + 1
|
|
return "send_all", [commands[i] for i in selected_idxs], None
|
|
|
|
elif action_l.startswith('e'):
|
|
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'<style fg="{c_edit}">{e_text}</style>'),
|
|
default=target, multiline=True, key_bindings=e_bindings
|
|
)
|
|
except (KeyboardInterrupt, EOFError):
|
|
if mission.get('active', False):
|
|
mission['paused'] = True
|
|
self.console.print("\n[yellow]⏸️ Mission Paused — Provide feedback to redirect, or type /cancel to abort.[/yellow]")
|
|
self.console.print("")
|
|
return "continue", None, None
|
|
|
|
if edited and edited.strip():
|
|
lines = [l.strip() for l in edited.split('\n') if l.strip()]
|
|
if mission.get('active', False):
|
|
mission['step'] = mission.get('step', 1) + 1
|
|
return "custom", None, lines
|
|
|
|
# User rejected/cancelled the commands
|
|
if mission.get('active', False):
|
|
mission['paused'] = True
|
|
self.console.print("\n[yellow]⏸️ Mission Paused — Provide feedback to redirect, or type /cancel to abort.[/yellow]")
|
|
|
|
self.console.print("")
|
|
return "continue", None, None
|
|
|
|
return "cancel", None, None
|
|
|
|
finally:
|
|
state['cancelled'] = True
|
|
|