Files
EvoScientist-Multi/EvoScientist/commands/implementation/session.py
T
Xi Zhang 92df3e1844 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
2026-04-24 10:38:44 +01:00

320 lines
10 KiB
Python

from __future__ import annotations
import inspect
from typing import ClassVar
from rich.table import Table
from ..base import Argument, Command, CommandContext
from ..manager import manager
class CompactCommand(Command):
"""Compact conversation to free context."""
name = "/compact"
description = "Compact conversation to free context"
requires_agent = True
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...cli.commands import (
build_compact_summary_renderable,
compact_conversation,
render_compact_result,
)
start_indicator = getattr(ctx.ui, "start_compacting_indicator", None)
stop_indicator = getattr(ctx.ui, "stop_compacting_indicator", None)
using_indicator = callable(start_indicator) and callable(stop_indicator)
if using_indicator:
maybe = start_indicator()
if inspect.isawaitable(maybe):
await maybe
else:
ctx.ui.append_system("Compacting conversation...")
try:
result = await compact_conversation(
agent=ctx.agent,
thread_id=ctx.thread_id,
input_tokens_hint=ctx.input_tokens_hint,
)
finally:
if using_indicator:
maybe = stop_indicator()
if inspect.isawaitable(maybe):
await maybe
ctx.ui.mount_renderable(render_compact_result(result))
summary_renderable = build_compact_summary_renderable(result)
if summary_renderable is not None:
ctx.ui.mount_renderable(summary_renderable)
# Push the reduced token count to the status bar immediately so it
# reflects the new context without waiting for the next LLM call.
# Only when input_tokens_hint was available: tokens_after is then
# LLM-level (includes system + tool overhead), matching the unit that
# _status_last_input_tokens expects. Without a hint, tokens_after is
# message-level only and would produce a misleadingly low reading.
if (
result.status == "ok"
and result.tokens_after > 0
and ctx.input_tokens_hint is not None
):
update_fn = getattr(ctx.ui, "update_status_after_compact", None)
if callable(update_fn):
update_fn(result.tokens_after)
class ThreadsCommand(Command):
"""List recent sessions."""
name = "/threads"
description = "List recent sessions"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...sessions import _format_relative_time, list_threads
threads = await list_threads(
limit=0,
include_message_count=True,
include_preview=True,
)
if not threads:
ctx.ui.append_system("No saved sessions.", style="yellow")
return
# Use protocol property to adapt output for non-interactive UIs (channels)
is_channel = not ctx.ui.supports_interactive
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=40 if is_channel else 50, no_wrap=True
)
table.add_column("Msgs" if is_channel else "Messages", justify="right")
if not is_channel:
table.add_column("Model", style="dim")
table.add_column("Last Used", style="dim")
for thread in threads:
thread_id_value = thread["thread_id"]
marker = " *" if thread_id_value == ctx.thread_id else ""
row = [
f"{thread_id_value}{marker}",
thread.get("preview", "") or "",
str(thread.get("message_count", 0)),
]
if not is_channel:
row.append(thread.get("model", "") or "")
row.append(_format_relative_time(thread.get("updated_at")))
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):
"""Resume a previous session."""
name = "/resume"
description = "Resume a previous session"
arguments: ClassVar[list[Argument]] = [
Argument(
name="thread_id",
type=str,
description="Thread ID or prefix to resume",
required=False,
)
]
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...sessions import (
get_thread_metadata,
list_threads,
)
arg = args[0] if args else ""
if not arg:
threads = await list_threads(
limit=0,
include_message_count=True,
include_preview=True,
)
if not threads:
ctx.ui.append_system("No sessions to resume.", style="yellow")
return
# Interactive pick
selected = await ctx.ui.wait_for_thread_pick(
threads,
current_thread=ctx.thread_id,
title=">>> Select session to resume <<<",
)
if selected is None:
return
arg = selected
# Resolve thread_id
resolved = await self._resolve_thread_id(arg, ctx)
if not resolved:
return
metadata = await get_thread_metadata(resolved)
restored_workspace = (metadata or {}).get("workspace_dir", "")
if restored_workspace:
ctx.workspace_dir = restored_workspace
ctx.thread_id = resolved
# Signal session change to UI
if hasattr(ctx.ui, "handle_session_resume"):
await ctx.ui.handle_session_resume(resolved, restored_workspace)
async def _resolve_thread_id(self, prefix: str, ctx: CommandContext) -> str | None:
from ...sessions import find_similar_threads, thread_exists
if await thread_exists(prefix):
return prefix
similar = await find_similar_threads(prefix)
if len(similar) == 1:
return similar[0]
if len(similar) > 1:
ctx.ui.append_system(
f"Ambiguous thread ID '{prefix}'. Use a longer prefix.",
style="yellow",
)
for thread in similar:
ctx.ui.append_system(f" - {thread}", style="dim")
return None
ctx.ui.append_system(f"Thread '{prefix}' not found.", style="red")
return None
class NewCommand(Command):
"""Start a new session."""
name = "/new"
description = "Start a new session"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
ctx.ui.start_new_session()
class ClearCommand(Command):
"""Clear chat history."""
name = "/clear"
description = "Clear chat history"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
ctx.ui.clear_chat()
class DeleteCommand(Command):
"""Delete a saved session."""
name = "/delete"
description = "Delete a saved session"
arguments: ClassVar[list[Argument]] = [
Argument(
name="thread_id",
type=str,
description="Thread ID or prefix to delete",
required=False,
)
]
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
from ...sessions import (
delete_thread,
find_similar_threads,
list_threads,
thread_exists,
)
arg = args[0] if args else ""
if not arg:
threads = await list_threads(
limit=0,
include_message_count=True,
include_preview=True,
)
if not threads:
ctx.ui.append_system("No sessions to delete.", style="yellow")
return
# Interactive pick
selected = await ctx.ui.wait_for_thread_pick(
threads,
current_thread=ctx.thread_id,
title=">>> Select session to delete <<<",
)
if selected is None:
return
arg = selected
# Resolve thread_id
resolved = None
if await thread_exists(arg):
resolved = arg
else:
similar = await find_similar_threads(arg)
if len(similar) == 1:
resolved = similar[0]
elif len(similar) > 1:
ctx.ui.append_system(
f"Ambiguous thread ID '{arg}'. Use a longer prefix.",
style="yellow",
)
for thread in similar:
ctx.ui.append_system(f" - {thread}", style="dim")
return
if not resolved:
ctx.ui.append_system(f"Session '{arg}' not found.", style="red")
return
if resolved == ctx.thread_id:
ctx.ui.append_system(
"Cannot delete the current session.",
style="yellow",
)
return
deleted = await delete_thread(resolved)
if deleted:
ctx.ui.append_system(f"Deleted session {resolved}.", style="green")
else:
ctx.ui.append_system(f"Session {resolved} not found.", style="red")
class ExitCommand(Command):
"""Quit EvoScientist."""
name = "/exit"
alias: ClassVar[list[str]] = ["/quit", "/q"]
description = "Quit EvoScientist"
async def execute(self, ctx: CommandContext, args: list[str]) -> None:
ctx.ui.force_quit()
# Register session commands
manager.register(CompactCommand())
manager.register(ThreadsCommand())
manager.register(ResumeCommand())
manager.register(NewCommand())
manager.register(ClearCommand())
manager.register(DeleteCommand())
manager.register(ExitCommand())