Refactor/cli command manager (#178)

* feat(cli): migrate command handling to CommandManager and enhance UI interactions

* feat(cli): add /clear and /help commands to enhance user experience

* feat(cli): implement /new and /resume commands with interactive session management

* Refactor MCP and Skills Command Handling

- Moved the interactive picker style to a centralized widget for consistency across MCP and Skills commands.
- Updated the MCP command to remove the old command dispatch logic, delegating to the new InstallMCPCommand.
- Enhanced the Skills command to utilize a new interactive picker for skill selection, improving user experience.
- Implemented cancellation handling in the picker to differentiate between user cancellations and empty selections.
- Added comprehensive tests for the new command structures and picker functionalities to ensure reliability.

* refactor(cli): streamline CommandManager dispatch and remove deprecated command set

* refactor(cli): enhance error handling and state management in ChannelCommand and RichCLICommandUI

* refactor(cli): update lifecycle callback terminology and improve async prompt handling in RichCLICommandUI

* refactor(cli): enhance SlashCommandCompleter to dynamically fetch workspace directory for autocompletion

* refactor(cli): unify quit handling in RichCLICommandUI with shared _stop helper

* refactor(cli): remove hardcoded slash commands and utilize command manager for dynamic completion

* refactor(cli): update MCP and skills command files for improved clarity and organization

* refactor(mcp_ui): remove unnecessary newline in _show_mcp_config function
This commit is contained in:
Xi Zhang
2026-04-24 11:38:44 +02:00
committed by GitHub
parent f704e3d761
commit 92df3e1844
24 changed files with 1779 additions and 957 deletions
-120
View File
@@ -19,7 +19,6 @@ from dataclasses import dataclass
from typing import Any
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from ..stream.console import console
@@ -682,125 +681,6 @@ def _print_channel_panel(channels: list[tuple[str, bool, str]]) -> None:
console.print()
def _cmd_channel(
args: str,
agent: Any,
thread_id: str,
*,
send_thinking: bool | None = None,
) -> None:
"""Start a channel in background using bus mode.
Usage:
/channel [telegram|discord|imessage] -- start channel (default from config)
/channel status -- show current channel status
/channel stop -- stop running channel
"""
global _cli_agent, _cli_thread_id
from ..config import load_config
app_config = load_config()
channel_type = args.strip().lower() if args and args.strip() else ""
if channel_type == "status":
running = _channels_running_list()
if running and _manager:
detailed = _manager.get_detailed_status()
table = Table(title="Channel Status", show_header=True, expand=False)
table.add_column("Channel", style="cyan")
table.add_column("Status")
table.add_column("Uptime", style="dim")
table.add_column("Rx", justify="right")
table.add_column("Tx", justify="right")
for ch_name in running:
info = detailed.get(ch_name, {})
secs = info.get("uptime_seconds", 0)
mins, s = divmod(int(secs), 60)
hours, mins = divmod(mins, 60)
uptime = f"{hours}h{mins:02d}m" if hours else f"{mins}m{s:02d}s"
rx = str(info.get("received", 0))
tx = str(info.get("sent", 0))
table.add_row(ch_name, "[green]running[/green]", uptime, rx, tx)
console.print(table)
console.print()
else:
console.print("[dim]No channel running[/dim]\n")
return
if not channel_type:
channel_type = app_config.channel_enabled
if not channel_type:
console.print("[yellow]No channel configured.[/yellow]")
console.print(
"[dim]Run[/dim] evosci onboard [dim]or specify:[/dim] /channel telegram\n"
)
return
requested = [t.strip() for t in channel_type.split(",") if t.strip()]
if _channels_is_running():
running = _channels_running_list()
results: list[tuple[str, bool, str]] = []
for ct in requested:
if ct in running:
results.append((ct, True, "already running"))
else:
try:
_add_channel_to_running_bus(
ct,
app_config,
send_thinking=send_thinking,
)
results.append((ct, True, "connected (bus)"))
except Exception as e:
results.append((ct, False, str(e)))
_print_channel_panel(results)
return
_cli_agent = agent
_cli_thread_id = thread_id
# Override channel_enabled for this invocation
original = app_config.channel_enabled
app_config.channel_enabled = channel_type
try:
_start_channels_bus_mode(
app_config,
agent,
thread_id,
send_thinking=send_thinking,
)
results = [(ct, True, "connected (bus)") for ct in requested]
except Exception as e:
results = [(ct, False, str(e)) for ct in requested]
finally:
app_config.channel_enabled = original
_print_channel_panel(results)
def _cmd_channel_stop(channel_type: str | None = None) -> None:
"""Stop background channel(s).
Args:
channel_type: Specific channel to stop, or None to stop all.
"""
if not _channels_is_running():
console.print("[dim]No channel running[/dim]\n")
return
if channel_type:
if not _channels_is_running(channel_type):
console.print(f"[dim]{channel_type} is not running[/dim]\n")
return
_channels_stop(channel_type)
console.print(f"[dim]{channel_type} stopped[/dim]\n")
else:
running = _channels_running_list()
_channels_stop()
console.print(f"[dim]{', '.join(running)} stopped[/dim]\n")
def _auto_start_channel(
agent: Any,
thread_id: str,
+161 -339
View File
@@ -5,6 +5,7 @@ import logging
import queue
import random
import sys
from collections.abc import Callable
from datetime import datetime
from typing import Any
@@ -26,19 +27,17 @@ from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untype
from rich.markdown import Markdown
from rich.markup import escape
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
import EvoScientist.cli.channel as _ch_mod
from ..commands.base import CommandContext
from ..commands.manager import manager as cmd_manager
from ..sessions import (
_format_relative_time,
delete_thread,
generate_thread_id,
get_checkpointer,
get_thread_messages,
get_thread_metadata,
list_threads,
resolve_thread_id_prefix,
thread_exists,
)
@@ -50,19 +49,11 @@ from .channel import (
ChannelMessage,
_auto_start_channel,
_channels_is_running,
_cmd_channel,
_cmd_channel_stop,
_message_queue,
_set_channel_response,
)
from .file_mentions import complete_file_mention, resolve_file_mentions
from .mcp_ui import _cmd_mcp
from .skills_cmd import (
_cmd_install_skill,
_cmd_install_skills,
_cmd_list_skills,
_cmd_uninstall_skill,
)
from .rich_command_ui import RichCLICommandUI
from .status_bar import (
SPINNER_FRAMES,
STATUS_BAD,
@@ -89,7 +80,6 @@ _channel_logger = logging.getLogger(__name__)
# Keeps references to fire-and-forget coroutines so they aren't GC'd mid-flight.
_background_tasks: set[asyncio.Task] = set()
# =============================================================================
# Banner
# =============================================================================
@@ -155,23 +145,6 @@ def print_banner(
# Slash-command completer
# =============================================================================
_SLASH_COMMANDS = [
("/current", "Show current session info"),
("/threads", "List recent sessions"),
("/resume", "Resume a previous session (prefix match)"),
("/delete", "Delete a saved session"),
("/new", "Start a new session"),
("/skills", "List installed skills"),
("/install-skill", "Add a skill from path or GitHub"),
("/uninstall-skill", "Remove an installed skill"),
("/evoskills", "Browse and install EvoSkills (optional: /evoskills <tag>)"),
("/mcp", "Manage MCP servers"),
("/channel", "Configure messaging channels"),
("/compact", "Compact conversation to free context"),
("/model", "Switch model (--save to persist)"),
("/exit", "Quit EvoScientist"),
]
_COMPLETION_STYLE = PtStyle.from_dict(
{
"completion-menu": "bg:default noreverse nounderline noitalic",
@@ -191,32 +164,28 @@ _COMPLETION_STYLE = PtStyle.from_dict(
}
)
# Style for questionary pickers — matches _COMPLETION_STYLE visual language:
# gray (#888888) for non-selected, bold for selected, no background changes.
_PICKER_STYLE = PtStyle.from_dict(
{
"questionmark": "#888888",
"question": "",
"pointer": "bold",
"highlighted": "bold",
"text": "#888888",
"answer": "bold",
}
)
class SlashCommandCompleter(Completer):
"""Autocomplete for slash commands and ``@file`` mentions."""
"""Autocomplete for slash commands and ``@file`` mentions.
def __init__(self, workspace_dir: str | None = None) -> None:
self._workspace_dir = workspace_dir
``workspace_getter`` is invoked on every keystroke so ``@file``
suggestions automatically follow ``/new`` / ``/resume`` workspace
changes without having to poke the completer from the callbacks.
"""
def __init__(
self,
workspace_getter: Callable[[], str | None] | None = None,
) -> None:
self._workspace_getter = workspace_getter or (lambda: None)
def get_completions(self, document, complete_event):
text = document.text_before_cursor
workspace_dir = self._workspace_getter()
# @file mention completion
if "@" in text:
candidates = complete_file_mention(text, self._workspace_dir)
candidates = complete_file_mention(text, workspace_dir)
if candidates:
# Replace from the last '@' token
import re as _re
@@ -230,7 +199,9 @@ class SlashCommandCompleter(Completer):
# Slash command completion
if not text.startswith("/"):
return
for cmd, desc in _SLASH_COMMANDS:
# ``list_commands`` is dedup'd on the Command instance so aliases
# (e.g. /quit, /q for /exit) don't appear as separate rows.
for cmd, desc in sorted(cmd_manager.list_commands()):
if cmd.startswith(text):
yield Completion(
cmd,
@@ -320,7 +291,9 @@ def cmd_interactive(
session = PromptSession(
history=FileHistory(history_file),
auto_suggest=AutoSuggestFromHistory(),
completer=SlashCommandCompleter(workspace_dir=workspace_dir),
completer=SlashCommandCompleter(
workspace_getter=lambda: state["workspace_dir"],
),
complete_style=CompleteStyle.COLUMN,
complete_while_typing=True,
style=_COMPLETION_STYLE,
@@ -372,6 +345,21 @@ def cmd_interactive(
on_progress=_on_mcp_progress,
)
def _on_status_after_compact(input_tokens: int) -> None:
"""Mirror inline /compact post-update: refresh both fields so the
next status render reflects the reduced context immediately.
``_refresh_status_snapshot`` is invoked by the dispatch block once
the command finishes (since it's async)."""
state["status_last_input_tokens"] = input_tokens
state["status_base_snapshot"] = make_usage_status_snapshot(
input_tokens,
model_name=model,
)
# ``rich_ui`` is constructed inside ``_async_main_loop`` so the
# lifecycle callbacks can close over ``checkpointer`` from
# ``get_checkpointer()``.
def _start_agent_load(checkpointer) -> None:
progress_tracker.prime()
agent_loader.start(
@@ -523,39 +511,6 @@ def cmd_interactive(
console.print(f"[red]Thread '{escape(tid)}' not found.[/red]")
return None
async def _cmd_threads():
"""Handle /threads command — show recent sessions."""
threads = await list_threads(
limit=0,
include_message_count=True,
include_preview=True,
)
if not threads:
console.print("[yellow]No saved sessions.[/yellow]")
return
table = Table(title="Sessions", show_header=True, header_style="bold cyan")
table.add_column("ID", style="bold")
table.add_column("Preview", style="dim", max_width=50, no_wrap=True)
table.add_column("Messages", justify="right")
table.add_column("Model", style="dim")
table.add_column("Last Used", style="dim")
for t in threads:
tid = t["thread_id"]
marker = " *" if tid == state["thread_id"] else ""
table.add_row(
f"{tid}{marker}",
t.get("preview", "") or "",
str(t.get("message_count", 0)),
t.get("model", "") or "",
_format_relative_time(t.get("updated_at")),
)
console.print()
console.print(table)
console.print(
"[dim] /resume[/dim] to continue a session [dim]/delete <id>[/dim] to remove [dim]/new[/dim] to start fresh"
)
console.print()
async def _render_history(thread_id: str):
"""Display conversation history for a resumed session."""
messages = await get_thread_messages(thread_id)
@@ -630,120 +585,80 @@ def cmd_interactive(
console.print("[dim]── End of history ──[/dim]")
console.print()
async def _cmd_resume(arg: str, checkpointer):
"""Handle /resume [id] — resume a previous session."""
if not arg:
# Show interactive session picker with conversation previews
threads = await list_threads(
limit=0,
include_message_count=True,
include_preview=True,
)
if not threads:
console.print("[yellow]No sessions to resume.[/yellow]")
return
import questionary
from .widgets.thread_selector import _build_items
choices = []
items = _build_items(threads)
for item in items:
if item["type"] == "header":
choices.append(
questionary.Separator(
f"\u2500\u2500 \U0001f4c2 {item['label']}"
)
)
elif item["type"] == "subheader":
choices.append(questionary.Separator(f" {item['label']}"))
else:
t = item["thread"]
tid = t["thread_id"]
preview = t.get("preview", "") or ""
msgs = t.get("message_count", 0)
model = t.get("model", "") or ""
when = _format_relative_time(t.get("updated_at"))
indent = " " if item.get("indented") else " "
parts = [f"{indent}{tid}"]
if preview:
parts.append(
preview[:40] + "\u2026" if len(preview) > 40 else preview
)
parts.append(f"({msgs} msgs)")
if model:
parts.append(model)
if when:
parts.append(when)
label = " ".join(parts)
choices.append(questionary.Choice(title=label, value=tid))
from prompt_toolkit.layout.dimension import Dimension
from questionary.prompts.common import InquirerControl
prompt = questionary.select(
"Select session to resume:",
choices=choices,
style=_PICKER_STYLE,
)
# Limit visible list to 10 rows with scrolling
for window in prompt.application.layout.find_all_windows():
if isinstance(window.content, InquirerControl):
window.height = Dimension(max=10)
break
selected = prompt.ask()
if selected is None:
return
arg = selected
resolved = await _resolve_thread_id(arg)
if not resolved:
return
meta = await get_thread_metadata(resolved)
ws = (meta or {}).get("workspace_dir", "") or state["workspace_dir"]
state["thread_id"] = resolved
state["resumed"] = True
if ws:
state["workspace_dir"] = ws
state["status_started_at"] = datetime.now()
state["status_last_input_tokens"] = None
# Rebuild the agent in the background so the resumed transcript
# and prompt render immediately; the next message awaits the load.
_start_agent_load(checkpointer)
await _refresh_status_snapshot(reset_streaming_text=True)
console.print(f"[green]Resumed session:[/green] [yellow]{resolved}[/yellow]")
if state["workspace_dir"]:
console.print(
f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]"
)
console.print()
await _render_history(resolved)
async def _cmd_delete(arg: str):
"""Handle /delete <id> — delete a saved session."""
if not arg:
console.print("[red]Usage: /delete <thread-id>[/red]")
return
resolved = await _resolve_thread_id(arg)
if not resolved:
return
if resolved == state["thread_id"]:
console.print("[red]Cannot delete the current session.[/red]")
return
deleted = await delete_thread(resolved)
if deleted:
console.print(f"[green]Deleted session {resolved}.[/green]")
else:
console.print(f"[red]Session {resolved} not found.[/red]")
async def _async_main_loop():
"""Async main loop with prompt_async and channel queue checking."""
nonlocal model
async with get_checkpointer() as checkpointer:
# Lifecycle callbacks (new / resume) need ``checkpointer``
# in scope — define the ``rich_ui`` adapter here rather than
# at the outer function level.
def _on_start_new_session() -> None:
"""NewCommand callback — rotate workspace (if not fixed),
issue a new thread id, reset session-scoped status fields,
and kick off background agent reload. The dispatch block
refreshes the status bar post-execute (symmetric with
/compact)."""
if not workspace_fixed:
state["workspace_dir"] = _create_session_workspace(run_name)
state["thread_id"] = generate_thread_id()
state["resumed"] = False
state["status_started_at"] = datetime.now()
state["status_last_input_tokens"] = None
_start_agent_load(checkpointer)
console.print(
f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]"
)
if state["workspace_dir"]:
console.print(
f"[dim]Workspace:[/dim] [cyan]"
f"{_shorten_path(state['workspace_dir'])}[/cyan]\n"
)
async def _on_handle_session_resume(
thread_id: str, workspace_dir: str | None
) -> None:
"""ResumeCommand callback — after the command resolves
the thread id + restores workspace from metadata, this
callback mutates REPL state, reloads the agent, and
renders conversation history."""
if workspace_dir:
state["workspace_dir"] = workspace_dir
state["thread_id"] = thread_id
state["resumed"] = True
state["status_started_at"] = datetime.now()
state["status_last_input_tokens"] = None
_start_agent_load(checkpointer)
await _refresh_status_snapshot(reset_streaming_text=True)
console.print(
f"[green]Resumed session:[/green] [yellow]{thread_id}[/yellow]"
)
if state["workspace_dir"]:
console.print(
f"[dim]Workspace:[/dim] [cyan]"
f"{_shorten_path(state['workspace_dir'])}[/cyan]"
)
console.print()
await _render_history(thread_id)
# Rich CLI collapses ``request_quit`` / ``force_quit`` into the
# same "break the prompt loop" effect — there's no equivalent
# of the TUI's double-press-to-confirm quit distinction. A
# shared ``_stop`` helper makes the intentional symmetry
# explicit instead of silently duplicating a lambda.
def _stop() -> None:
state["running"] = False
rich_ui = RichCLICommandUI(
console,
on_request_quit=_stop,
on_force_quit=_stop,
on_clear_chat=lambda: console.clear(),
on_status_after_compact=_on_status_after_compact,
on_start_new_session=_on_start_new_session,
on_handle_session_resume=_on_handle_session_resume,
)
# Handle --thread-id resume
if thread_id:
resolved = await _resolve_thread_id(thread_id)
@@ -1036,168 +951,75 @@ def cmd_interactive(
_print_separator()
# Special commands
if user_input.lower() in ("/exit", "/quit", "/q"):
state["running"] = False
break
if user_input.lower() == "/threads":
await _cmd_threads()
continue
if user_input.lower().startswith("/resume"):
arg = user_input[len("/resume") :].strip()
await _cmd_resume(arg, checkpointer)
continue
if user_input.lower().startswith("/delete"):
arg = user_input[len("/delete") :].strip()
await _cmd_delete(arg)
continue
if user_input.lower() == "/new":
# New session: new thread; workspace only changes if not fixed
if not workspace_fixed:
state["workspace_dir"] = _create_session_workspace(
run_name
)
state["thread_id"] = generate_thread_id()
state["resumed"] = False
state["status_started_at"] = datetime.now()
state["status_last_input_tokens"] = None
# Background agent reload — next message awaits it.
_start_agent_load(checkpointer)
await _refresh_status_snapshot(reset_streaming_text=True)
console.print(
f"[green]New session:[/green] [yellow]{state['thread_id']}[/yellow]"
)
if state["workspace_dir"]:
console.print(
f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]\n"
)
continue
if user_input.lower() == "/current":
console.print(
f"[dim]Thread:[/dim] [yellow]{state['thread_id']}[/yellow]"
)
if state["workspace_dir"]:
console.print(
f"[dim]Workspace:[/dim] [cyan]{_shorten_path(state['workspace_dir'])}[/cyan]"
)
if memory_dir:
console.print(
f"[dim]Memory dir:[/dim] [cyan]{_shorten_path(memory_dir)}[/cyan]"
)
console.print()
continue
if user_input.lower() == "/skills":
_cmd_list_skills()
continue
if user_input.lower().startswith("/install-skill"):
source = user_input[len("/install-skill") :].strip()
_cmd_install_skill(source)
continue
if user_input.lower().startswith("/uninstall-skill"):
name = user_input[len("/uninstall-skill") :].strip()
_cmd_uninstall_skill(name)
continue
if user_input.lower().startswith("/evoskills"):
browse_args = user_input[len("/evoskills") :].strip()
_cmd_install_skills(browse_args)
continue
if user_input.lower().startswith("/mcp"):
_cmd_mcp(user_input[len("/mcp") :])
continue
if user_input.lower().startswith("/channel"):
args = user_input[len("/channel") :].strip()
if args.lower().startswith("stop"):
stop_arg = args[len("stop") :].strip()
_cmd_channel_stop(stop_arg or None)
else:
await _await_agent_ready()
_cmd_channel(
args,
agent_loader.agent,
state["thread_id"],
send_thinking=channel_send_thinking,
)
continue
if user_input.lower() == "/compact":
from .commands import (
build_compact_summary_renderable,
compact_conversation,
render_compact_result,
)
await _await_agent_ready()
with console.status(
"[cyan]Compacting conversation...[/cyan]"
):
result = await compact_conversation(
agent=agent_loader.agent,
thread_id=state["thread_id"],
input_tokens_hint=state.get(
"status_last_input_tokens"
),
)
console.print(render_compact_result(result))
summary_renderable = build_compact_summary_renderable(
result
)
if summary_renderable is not None:
console.print(summary_renderable)
if result.status == "ok" and result.tokens_after > 0:
state["status_last_input_tokens"] = result.tokens_after
state["status_base_snapshot"] = (
make_usage_status_snapshot(
result.tokens_after,
model_name=model,
)
)
await _refresh_status_snapshot(
reset_streaming_text=True,
)
continue
if user_input.lower().startswith("/model"):
from ..commands.base import CommandContext
from ..commands.manager import manager as cmd_manager
from ..EvoScientist import _ensure_config
from .rich_command_ui import RichCLICommandUI
# /model is ``needs_agent=False`` — it builds its
# own agent — so we don't wait for the current
# load; /model is the way to fix a broken one.
# ==== Shared CommandManager dispatch ====
# Every registered slash command routes through the
# manager. Unresolved input (non-slash, typo) falls
# through to the agent message path below.
_parsed = cmd_manager.resolve(user_input)
if _parsed is not None:
_cmd, _cmd_args = _parsed
_agent_for_ctx: Any = agent_loader.agent
if _cmd.needs_agent(_cmd_args):
_agent_for_ctx = await _await_agent_ready()
ctx = CommandContext(
agent=None,
agent=_agent_for_ctx,
thread_id=state["thread_id"],
ui=RichCLICommandUI(console),
ui=rich_ui,
workspace_dir=state["workspace_dir"],
checkpointer=checkpointer,
config=config,
input_tokens_hint=state.get("status_last_input_tokens"),
)
await cmd_manager.execute(user_input, ctx)
if ctx.agent is not None:
# ExitCommand signals quit via ``force_quit`` →
# callback flips ``state["running"]`` to False.
if not state["running"]:
break
# Agent swap (e.g. /model successfully built a
# new agent): adopt into loader + reset status
# snapshot + sync channel globals.
agent_swapped = (
ctx.agent is not None
and ctx.agent is not _agent_for_ctx
)
if agent_swapped:
from ..EvoScientist import _ensure_config
agent_loader.adopt(ctx.agent)
cfg = _ensure_config()
model = cfg.model
state["status_base_snapshot"] = (
make_empty_status_snapshot(model)
)
await _refresh_status_snapshot(
reset_streaming_text=True,
)
if _channels_is_running():
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = state["thread_id"]
# Commands that mutate status fields need an
# async refresh here (/compact + /new use sync
# callbacks; /model swaps the agent). /resume
# awaits its own refresh inline inside the
# async callback.
if agent_swapped or _cmd.name in ("/compact", "/new"):
await _refresh_status_snapshot(
reset_streaming_text=True,
)
continue
# Unknown slash command (typo) — short-circuit so
# it doesn't get forwarded to the agent, which
# would waste tokens interpreting the nonsense.
if user_input.lstrip().startswith("/"):
bad_cmd = user_input.split(None, 1)[0]
console.print(
f"[red]Unknown command:[/red] {escape(bad_cmd)}"
)
console.print(
"[dim]Type /help to see available commands.[/dim]"
)
console.print()
continue
# Resolve @file mentions — inject file contents inline
+3 -3
View File
@@ -21,7 +21,7 @@ from ..mcp.registry import (
install_mcp_servers,
)
from ..stream.console import console
from .interactive import _PICKER_STYLE
from .widgets.thread_selector import PICKER_STYLE
_INSTALLED_INDICATOR = ("fg:#4caf50", "\u2713 ")
@@ -46,7 +46,7 @@ def _checkbox_ask(choices, message: str, **kwargs):
return questionary.checkbox(
message,
choices=choices,
style=_PICKER_STYLE,
style=PICKER_STYLE,
qmark="\u276f",
**kwargs,
).ask()
@@ -89,7 +89,7 @@ def _browse_and_select(
selected_tag = questionary.select(
"Filter by tag:",
choices=tag_choices,
style=_PICKER_STYLE,
style=PICKER_STYLE,
qmark="\u276f",
).ask()
+1 -127
View File
@@ -1,4 +1,4 @@
"""MCP server display, operations, and /mcp slash-command dispatcher."""
"""Shared UI helpers for MCP server display and operations (used by the Typer `mcp` commands)."""
from typing import Any
@@ -181,129 +181,3 @@ def _show_mcp_config(name: str = "", *, show_blank_line: bool = True) -> str:
if show_blank_line:
console.print()
return "ok"
def _cmd_mcp_add(args_str: str) -> None:
"""Handle ``/mcp add ...``."""
import shlex
from ..mcp import parse_mcp_add_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp add <name> <command-or-url> [args...]")
console.print()
console.print(
"[dim]Transport is auto-detected: URLs \u2192 http, commands \u2192 stdio[/dim]"
)
console.print()
console.print("[bold]Examples:[/bold]")
console.print(
" /mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking"
)
console.print(" /mcp add docs-langchain https://docs.langchain.com/mcp")
console.print(
" /mcp add my-sse http://localhost:9090/sse --transport sse --expose-to research-agent"
)
console.print()
console.print("[dim]Options:[/dim]")
console.print(" --transport T Transport type (default: auto-detect)")
console.print(
" --tools t1,t2 Tool allowlist (supports wildcards: *_exa, read_*)"
)
console.print(" --expose-to a1,a2 Target agents (default: main)")
console.print(" --header Key:Value HTTP header (repeatable)")
console.print(" --env KEY=VALUE Env var for stdio (repeatable)")
console.print(
" --env-ref KEY Env var as runtime ${KEY} reference (repeatable)"
)
console.print()
return
try:
tokens = shlex.split(args_str)
kwargs = parse_mcp_add_args(tokens)
_mcp_add_server_from_kwargs(kwargs, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
def _cmd_mcp_edit(args_str: str) -> None:
"""Handle ``/mcp edit <name> --field value ...``."""
import shlex
from ..mcp import parse_mcp_edit_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp edit <name> --<field> <value> ...")
console.print()
console.print(
"[dim]Fields:[/dim] --transport, --command, --url, --args, --tools, --expose-to, --header, --env"
)
console.print(
"[dim]Use[/dim] --tools none [dim]or[/dim] --expose-to none [dim]to clear a field.[/dim]"
)
console.print()
console.print("[bold]Examples:[/bold]")
console.print(" /mcp edit filesystem --expose-to main,code-agent")
console.print(" /mcp edit filesystem --tools read_file,write_file")
console.print(" /mcp edit my-api --url http://new-host:8080/mcp")
console.print(" /mcp edit my-api --tools none")
console.print()
return
try:
tokens = shlex.split(args_str)
name, fields = parse_mcp_edit_args(tokens)
_mcp_edit_server_fields(name, fields, show_reload_hint=True)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
def _cmd_mcp_remove(name: str) -> None:
"""Handle ``/mcp remove <name>``."""
_mcp_remove_server(name, show_reload_hint=True)
console.print()
def _cmd_mcp_config(name: str) -> None:
"""Handle ``/mcp config [name]``."""
_show_mcp_config(name, show_blank_line=True)
def _cmd_mcp(args: str) -> None:
"""Dispatch ``/mcp`` subcommands."""
args = args.strip()
if not args:
_mcp_list_servers()
return
parts = args.split(maxsplit=1)
subcmd = parts[0].lower()
subargs = parts[1] if len(parts) > 1 else ""
if subcmd == "list":
_mcp_list_servers()
elif subcmd == "add":
_cmd_mcp_add(subargs)
elif subcmd == "edit":
_cmd_mcp_edit(subargs)
elif subcmd == "remove":
_cmd_mcp_remove(subargs)
elif subcmd == "config":
_cmd_mcp_config(subargs)
elif subcmd == "install":
from .mcp_install_cmd import _cmd_install_mcp
_cmd_install_mcp(subargs)
else:
console.print("[bold]MCP commands:[/bold]")
console.print(" /mcp List configured servers")
console.print(" /mcp list List configured servers")
console.print(" /mcp config Show detailed server config")
console.print(" /mcp add ... Add a server")
console.print(" /mcp edit ... Edit an existing server")
console.print(" /mcp remove ... Remove a server")
console.print(" /mcp install ... Browse and install servers")
console.print()
+171 -41
View File
@@ -1,12 +1,21 @@
"""CommandUI Protocol adapter for the Rich CLI surface.
Methods not exercised by the currently-migrated commands raise
``NotImplementedError`` rather than silently returning ``None``, so
future callers fail loudly instead of pretending the command ran.
Lifecycle methods (``request_quit``, ``force_quit``, ``clear_chat``,
``start_new_session``, ``handle_session_resume``, ``update_status_after_compact``)
are callback-driven: when their corresponding ``on_*`` constructor kwarg
is ``None``, the method is a silent no-op, mirroring
``ChannelCommandUI``'s fallback pattern. Callers that need a specific
side-effect (REPL quit flag flip, status-bar refresh, …) wire the
callback at construction time; non-interactive surfaces (tests,
alternate REPLs) can leave callbacks unset without crashing.
``wait_for_*`` methods return ``None`` on cancel / fallback and are
always safe to ``await``.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from typing import Any
from rich.console import Console
@@ -16,17 +25,42 @@ from ..commands.base import CommandUI
class RichCLICommandUI(CommandUI):
"""CommandUI implementation that prints to a Rich ``Console``."""
"""CommandUI implementation that prints to a Rich ``Console``.
def __init__(self, console: Console) -> None:
Commands that affect CLI-closure state (session lifecycle, exit flag,
status-bar snapshot) go through optional callbacks wired by the REPL.
This mirrors ``ChannelCommandUI``'s injection pattern and keeps
``interactive.py``'s ``state`` dict as the single source of truth.
"""
def __init__(
self,
console: Console,
*,
on_request_quit: Callable[[], None] | None = None,
on_force_quit: Callable[[], None] | None = None,
on_clear_chat: Callable[[], None] | None = None,
on_status_after_compact: Callable[[int], None] | None = None,
on_start_new_session: Callable[[], None] | None = None,
on_handle_session_resume: (
Callable[[str, str | None], Awaitable[None]] | None
) = None,
) -> None:
self.console = console
self._on_request_quit = on_request_quit
self._on_force_quit = on_force_quit
self._on_clear_chat = on_clear_chat
self._on_status_after_compact = on_status_after_compact
self._on_start_new_session = on_start_new_session
self._on_handle_session_resume = on_handle_session_resume
# Bound ``console.status(...)`` context manager used by
# /compact's start/stop indicator pair.
self._compact_status_ctx: Any = None
# ── Core I/O ─────────────────────────────────────────────
@property
def supports_interactive(self) -> bool:
# Rich CLI has no picker widget, but wait_for_* fall back to
# printing a table and returning None (see wait_for_model_pick).
return True
def append_system(self, text: str, style: str = "dim") -> None:
@@ -73,54 +107,150 @@ class RichCLICommandUI(CommandUI):
``ctx.agent`` change post-``cmd_manager.execute``."""
return
# ── Not yet migrated ────────────────────────────────────
# ── Interactive pickers ────────────────────────────────
async def wait_for_thread_pick(
self, threads: list[dict], current_thread: str, title: str
) -> str | None:
raise NotImplementedError(
"RichCLICommandUI.wait_for_thread_pick — implement when "
"migrating /threads / /resume"
"""Interactive workspace-grouped thread picker using ``questionary``.
Ported from the pre-migration ``_cmd_resume`` implementation.
Returns the selected ``thread_id`` string, or ``None`` on cancel.
Callers (``ResumeCommand``/``DeleteCommand``) pre-check for
empty thread lists before invoking this method.
"""
import questionary # type: ignore[import-untyped]
from prompt_toolkit.layout.dimension import ( # type: ignore[import-untyped]
Dimension,
)
from questionary.prompts.common import ( # type: ignore[import-untyped]
InquirerControl,
)
from ..sessions import _format_relative_time
from .widgets.thread_selector import PICKER_STYLE, _build_items
choices: list[Any] = []
for item in _build_items(threads):
if item["type"] == "header":
choices.append(questionary.Separator(f"── \U0001f4c2 {item['label']}"))
elif item["type"] == "subheader":
choices.append(questionary.Separator(f" {item['label']}"))
else:
t = item["thread"]
tid = t["thread_id"]
preview = t.get("preview", "") or ""
msgs = t.get("message_count", 0)
model = t.get("model", "") or ""
when = _format_relative_time(t.get("updated_at"))
indent = " " if item.get("indented") else " "
marker = " *" if tid == current_thread else ""
parts = [f"{indent}{tid}{marker}"]
if preview:
parts.append(preview[:40] + "…" if len(preview) > 40 else preview)
parts.append(f"({msgs} msgs)")
if model:
parts.append(model)
if when:
parts.append(when)
label = " ".join(parts)
choices.append(questionary.Choice(title=label, value=tid))
prompt = questionary.select(title, choices=choices, style=PICKER_STYLE)
# Limit visible list to 10 rows with scrolling. Touches
# questionary/prompt-toolkit private internals so guard against
# library-shape changes — picker stays functional at default
# height even if the cap fails.
try:
for window in prompt.application.layout.find_all_windows():
if isinstance(window.content, InquirerControl):
window.height = Dimension(max=10)
break
except Exception:
pass
# ``ask_async`` (questionary >= 2.0.1) avoids blocking the
# asyncio event loop while the user interacts with the picker.
return await prompt.ask_async()
# ── Lifecycle callbacks ───────────────────────────────
def clear_chat(self) -> None:
if self._on_clear_chat is not None:
self._on_clear_chat()
else:
self.console.clear()
def request_quit(self) -> None:
if self._on_request_quit is not None:
self._on_request_quit()
def force_quit(self) -> None:
if self._on_force_quit is not None:
self._on_force_quit()
def start_new_session(self) -> None:
if self._on_start_new_session is not None:
self._on_start_new_session()
async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None
) -> None:
if self._on_handle_session_resume is not None:
await self._on_handle_session_resume(thread_id, workspace_dir)
# /compact indicator pair — duck-typed by ``CompactCommand`` via
# ``getattr``, not declared on the ``CommandUI`` Protocol.
def start_compacting_indicator(self) -> None:
# Idempotent: close any lingering context before starting a new
# one so a double-call (e.g. two overlapping /compact attempts
# via the message queue) can't leak a Rich Live handle.
if self._compact_status_ctx is not None:
try:
self._compact_status_ctx.__exit__(None, None, None)
except Exception:
pass
self._compact_status_ctx = None
status = self.console.status("[cyan]Compacting conversation...[/cyan]")
status.__enter__()
self._compact_status_ctx = status
def stop_compacting_indicator(self) -> None:
ctx = self._compact_status_ctx
self._compact_status_ctx = None
if ctx is not None:
try:
ctx.__exit__(None, None, None)
except Exception:
pass
def update_status_after_compact(self, input_tokens: int) -> None:
if self._on_status_after_compact is not None:
self._on_status_after_compact(input_tokens)
# ── Skill / MCP browse (delegated to worker threads) ──
async def wait_for_skill_browse(
self, index: list[dict], installed_names: set[str], pre_filter_tag: str
) -> list[str] | None:
raise NotImplementedError(
"RichCLICommandUI.wait_for_skill_browse — implement when "
"migrating /skills / /evoskills"
"""Delegate to the extracted questionary picker on a worker
thread — questionary blocks the event loop so the call must
not happen on the main asyncio thread."""
import asyncio
from .skills_cmd import _pick_skills_interactive
return await asyncio.to_thread(
_pick_skills_interactive, index, installed_names, pre_filter_tag
)
async def wait_for_mcp_browse(
self, servers: list, installed_names: set[str], pre_filter_tag: str
) -> list | None:
raise NotImplementedError(
"RichCLICommandUI.wait_for_mcp_browse — implement when migrating /mcp"
)
"""Delegate to the MCP browse picker on a worker thread."""
import asyncio
def clear_chat(self) -> None:
raise NotImplementedError(
"RichCLICommandUI.clear_chat — implement when migrating /clear"
)
from .mcp_install_cmd import _browse_and_select
def request_quit(self) -> None:
raise NotImplementedError(
"RichCLICommandUI.request_quit — implement when migrating /exit"
)
def force_quit(self) -> None:
raise NotImplementedError(
"RichCLICommandUI.force_quit — implement when migrating /exit"
)
def start_new_session(self) -> None:
raise NotImplementedError(
"RichCLICommandUI.start_new_session — implement when migrating /new"
)
async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None
) -> None:
raise NotImplementedError(
"RichCLICommandUI.handle_session_resume — implement when migrating /resume"
return await asyncio.to_thread(
_browse_and_select, servers, installed_names, pre_filter_tag
)
+29 -212
View File
@@ -1,160 +1,30 @@
"""Slash commands for skill management: /skills, /install-skill, /uninstall-skill, /evoskills."""
from pathlib import Path
"""Shared UI helpers for skill-management commands (picker used by /evoskills)."""
from ..stream.console import console
from .agent import _shorten_path
def _cmd_list_skills() -> None:
"""List all available skills (workspace, global, and built-in)."""
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
from ..tools.skills_manager import list_skills
def _pick_skills_interactive(
index: list[dict],
installed_names: set[str],
pre_filter_tag: str,
) -> list[str] | None:
"""Interactive questionary picker for EvoSkills browse.
skills = list_skills(include_system=True)
Two-phase picker:
1. tag filter — ``questionary.select`` (skipped if ``pre_filter_tag``)
2. multi-select — ``questionary.checkbox`` with installed items disabled
if not skills:
console.print("[dim]No skills available.[/dim]")
console.print("[dim]Install with:[/dim] /install-skill <path-or-url>")
console.print(
f"[dim]Global skills:[/dim] [cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan]"
)
console.print()
return
workspace_skills = [s for s in skills if s.source == "workspace"]
global_skills = [s for s in skills if s.source == "global"]
builtin_skills = [s for s in skills if s.source == "builtin"]
sections = [
("Workspace Skills", workspace_skills, "green"),
("Global Skills", global_skills, "cyan"),
("Built-in Skills", builtin_skills, "blue"),
]
printed = False
for title, group, color in sections:
if not group:
continue
if printed:
console.print()
console.print(f"[bold]{title}[/bold] ({len(group)}):")
for skill in group:
tags_str = f" [dim]({', '.join(skill.tags)})[/dim]" if skill.tags else ""
console.print(
f" [{color}]{skill.name}[/{color}] - {skill.description}{tags_str}"
)
printed = True
console.print(
f"\n[dim]Global skills:[/dim] [cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan]"
)
console.print(
f"[dim]Workspace skills:[/dim] [green]{_shorten_path(str(USER_SKILLS_DIR))}[/green]"
)
console.print()
def _cmd_install_skill(args: str) -> None:
"""Install a skill from local path or GitHub URL.
By default, installs to the global skills directory (~/.evoscientist/skills/).
Append --local to install to the current workspace instead.
Usage: /install-skill <path-or-url> [--local]
"""
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
from ..tools.skills_manager import install_skill
# Parse --local flag out of the args string
local = "--local" in args.split()
source = args.replace("--local", "").strip()
if not source:
console.print("[red]Usage:[/red] /install-skill <path-or-url> [--local]")
console.print("[dim]Examples:[/dim]")
console.print(" /install-skill ./my-skill")
console.print(
" /install-skill https://github.com/user/repo/tree/main/skill-name"
)
console.print(" /install-skill user/repo@skill-name")
console.print(
" /install-skill ./my-skill --local [dim](workspace only)[/dim]"
)
console.print()
return
dest_label = (
f"[cyan]{_shorten_path(str(USER_SKILLS_DIR))}[/cyan] [dim](workspace)[/dim]"
if local
else f"[cyan]{_shorten_path(str(GLOBAL_SKILLS_DIR))}[/cyan] [dim](global)[/dim]"
)
console.print(f"[dim]Installing skill from:[/dim] {source}")
console.print(f"[dim]Destination:[/dim] {dest_label}")
result = install_skill(source, global_install=not local)
if result.get("batch"):
# Batch install — multiple skills
for item in result.get("installed", []):
console.print(f"[green]Installed:[/green] {item['name']}")
console.print(
f" [dim]Description:[/dim] {item.get('description', '(none)')}"
)
console.print(
f" [dim]Path:[/dim] [cyan]{_shorten_path(item['path'])}[/cyan]"
)
for item in result.get("failed", []):
console.print(f"[red]Failed:[/red] {item['name']} — {item['error']}")
installed_count = len(result.get("installed", []))
if installed_count:
console.print(f"\n[green]{installed_count} skill(s) installed.[/green]")
console.print("[dim]Reload with /new to apply.[/dim]")
elif result["success"]:
console.print(f"[green]Installed:[/green] {result['name']}")
console.print(f"[dim]Description:[/dim] {result.get('description', '(none)')}")
console.print(f"[dim]Path:[/dim] [cyan]{_shorten_path(result['path'])}[/cyan]")
console.print()
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
def _cmd_uninstall_skill(name: str) -> None:
"""Uninstall a user-installed skill."""
from ..tools.skills_manager import uninstall_skill
if not name:
console.print("[red]Usage:[/red] /uninstall-skill <skill-name>")
console.print("[dim]Use /skills to see installed skills.[/dim]")
console.print()
return
result = uninstall_skill(name)
if result["success"]:
console.print(f"[green]Uninstalled:[/green] {name}")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
def _cmd_install_skills(args: str = "") -> None:
"""Browse and install skills from the EvoSkills repository.
Args:
args: Optional tag name to pre-filter (e.g. "core").
Returns:
list of ``install_source`` strings selected by the user,
``None`` if the user cancelled at either phase, or
``[]`` if nothing was selectable / all-installed in the filter.
"""
from collections import Counter
import questionary
from questionary import Choice
from ..paths import GLOBAL_SKILLS_DIR, USER_SKILLS_DIR
from ..tools.skills_manager import fetch_remote_skill_index, install_skill
from .interactive import _PICKER_STYLE
from .widgets.thread_selector import PICKER_STYLE
# Installed-item indicator style for disabled checkbox choices.
_INSTALLED_INDICATOR = ("fg:#4caf50", "✓ ")
@@ -179,46 +49,24 @@ def _cmd_install_skills(args: str = "") -> None:
return questionary.checkbox(
message,
choices=choices,
style=_PICKER_STYLE,
style=PICKER_STYLE,
qmark="❯",
**kwargs,
).ask()
finally:
InquirerControl._get_choice_tokens = original
# Step 1: Fetch remote index
console.print("[dim]Fetching skill index...[/dim]")
try:
index = fetch_remote_skill_index()
except Exception as e:
console.print(f"[red]Failed to fetch skill index: {e}[/red]")
console.print(
"[dim]Try installing directly: /install-skill EvoScientist/EvoSkills@skills[/dim]"
)
console.print()
return
pre_filter_tag = (pre_filter_tag or "").strip().lower()
if not index:
console.print("[yellow]No skills found in the repository.[/yellow]")
console.print()
return
# Detect already-installed skills (both global and workspace tiers)
installed_names: set[str] = set()
for skills_dir in (Path(GLOBAL_SKILLS_DIR), Path(USER_SKILLS_DIR)):
if skills_dir.exists():
installed_names.update(e.name for e in skills_dir.iterdir() if e.is_dir())
pre_filter_tag = args.strip().lower() if args else ""
# Step 2: Tag filter (skip if pre-filtered via args)
# Phase 1: tag filter (skip if pre-filtered via args)
if pre_filter_tag:
filtered = [
s for s in index if pre_filter_tag in [t.lower() for t in s.get("tags", [])]
]
if not filtered:
console.print(f"[yellow]No skills found with tag: {args.strip()}[/yellow]")
# Show available tags
console.print(
f"[yellow]No skills found with tag: {pre_filter_tag}[/yellow]"
)
tag_counter: Counter[str] = Counter()
for s in index:
for t in s.get("tags", []):
@@ -227,10 +75,8 @@ def _cmd_install_skills(args: str = "") -> None:
sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
tags_str = ", ".join(f"{tag} ({count})" for tag, count in sorted_tags)
console.print(f"[dim]Available tags: {tags_str}[/dim]")
console.print()
return
return []
else:
# Build tag choices for interactive picker
tag_counter = Counter()
for s in index:
for t in s.get("tags", []):
@@ -244,13 +90,12 @@ def _cmd_install_skills(args: str = "") -> None:
selected_tag = questionary.select(
"Filter by tag:",
choices=tag_choices,
style=_PICKER_STYLE,
style=PICKER_STYLE,
qmark="❯",
).ask()
if selected_tag is None:
console.print()
return
return None
if selected_tag == "__all__":
filtered = index
@@ -261,14 +106,12 @@ def _cmd_install_skills(args: str = "") -> None:
if selected_tag in [t.lower() for t in s.get("tags", [])]
]
# Step 3: Skill selection checkbox
all_installed = all(s["name"] in installed_names for s in filtered)
if all_installed:
# Phase 2: skill selection checkbox
if all(s["name"] in installed_names for s in filtered):
console.print(
"[green]All skills in this category are already installed.[/green]"
)
console.print()
return
return []
choices = []
for s in filtered:
@@ -294,31 +137,5 @@ def _cmd_install_skills(args: str = "") -> None:
selected = _checkbox_ask(choices, "Select skills to install:")
if selected is None:
console.print()
return
if not selected:
console.print("[dim]No skills selected.[/dim]")
console.print()
return
# Step 4: Install selected skills (default: global)
installed_count = 0
for source in selected:
result = install_skill(source, global_install=True)
if result.get("batch"):
for item in result.get("installed", []):
console.print(f"[green]Installed:[/green] {item['name']}")
installed_count += 1
for item in result.get("failed", []):
console.print(f"[red]Failed:[/red] {item['name']} — {item['error']}")
elif result.get("success"):
console.print(f"[green]Installed:[/green] {result['name']}")
installed_count += 1
else:
console.print(f"[red]Failed:[/red] {result.get('error', 'unknown')}")
if installed_count:
console.print(f"\n[green]{installed_count} skill(s) installed.[/green]")
console.print("[dim]Reload with /new to apply.[/dim]")
console.print()
return None
return list(selected)
@@ -18,6 +18,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from prompt_toolkit.styles import Style as PtStyle # type: ignore[import-untyped]
from rich.text import Text
from textual.binding import Binding, BindingType
from textual.containers import Container
@@ -30,6 +31,22 @@ if TYPE_CHECKING:
from textual.app import ComposeResult
# Style for questionary pickers used by the Rich CLI ``/resume`` and
# ``/delete`` interactive selectors. Matches the slash-completion menu's
# visual language: gray (#888888) for non-selected, bold for selected,
# no background changes.
PICKER_STYLE = PtStyle.from_dict(
{
"questionmark": "#888888",
"question": "",
"pointer": "bold",
"highlighted": "bold",
"text": "#888888",
"answer": "bold",
}
)
# ---------------------------------------------------------------------------
# Path helpers
# ---------------------------------------------------------------------------
@@ -98,6 +98,12 @@ class ChannelCommand(Command):
ctx.ui.append_system(f"Adding channel(s): {', '.join(requested)}...")
from ...cli.channel import _add_channel_to_running_bus
# Sync CLI-side globals up-front so partial-success states (one
# channel attached, next one raises) still leave
# _auto_start_channel observing the latest agent/thread refs.
# See cli/channel.py _cli_agent / _cli_thread_id.
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = ctx.thread_id
try:
for ct in requested:
_add_channel_to_running_bus(ct, config, send_thinking=send_thinking)
@@ -131,6 +137,12 @@ class ChannelCommand(Command):
ctx.thread_id,
send_thinking=send_thinking,
)
# Sync CLI-side globals so _auto_start_channel observes the
# latest agent/thread refs (see cli/channel.py _cli_agent /
# _cli_thread_id). The pre-migration inline helper set these
# in the same branch.
_ch_mod._cli_agent = ctx.agent
_ch_mod._cli_thread_id = ctx.thread_id
# Show status panel
if _ch_mod._manager:
@@ -113,6 +113,11 @@ class ThreadsCommand(Command):
table.add_row(*row)
ctx.ui.mount_renderable(table)
if not is_channel:
ctx.ui.append_system(
" /resume to continue a session "
"/delete <id> to remove /new to start fresh",
)
class ResumeCommand(Command):
@@ -213,11 +213,18 @@ class InstallSkills(Command):
pre_filter_tag=tag,
)
if not selected_sources:
# ``None`` means user cancelled (Esc / Ctrl-C). An empty list means
# the picker handled a "nothing to do" state (all-installed / no
# tag matches) and already printed its own specific message; the
# outer layer should stay silent rather than claim a cancel.
if selected_sources is None:
if not is_channel:
ctx.ui.append_system("Browse cancelled.", style="dim")
return
if not selected_sources:
return
# Install selected skills
installed_count = 0
for source in selected_sources:
+1 -1
View File
@@ -989,7 +989,7 @@ def _resolve_ask_user_prompt(ask_user_data: dict) -> dict:
"""
import questionary # type: ignore[import-untyped]
from ..cli.interactive import _PICKER_STYLE
from ..cli.widgets.thread_selector import PICKER_STYLE as _PICKER_STYLE
questions = ask_user_data.get("questions", [])
if not questions: