From f6a3f894042420aaff40ec9c951e4e89353e06d4 Mon Sep 17 00:00:00 2001 From: "Michael J. Jabbour" Date: Tue, 3 Mar 2026 14:32:51 -0500 Subject: [PATCH] feat: copyable shell commands and persistent footer toolbar - Add click-to-copy functionality for shell command blocks - Implement persistent footer toolbar for improved UX - Update message renderer for enhanced command display --- amplifier_app_cli/main.py | 74 +++++++++++++-- amplifier_app_cli/ui/message_renderer.py | 113 +++++++++++++++++++++-- uv.lock | 4 +- 3 files changed, 174 insertions(+), 17 deletions(-) diff --git a/amplifier_app_cli/main.py b/amplifier_app_cli/main.py index 467b55aa..49334923 100644 --- a/amplifier_app_cli/main.py +++ b/amplifier_app_cli/main.py @@ -1318,7 +1318,10 @@ async def _process_runtime_mentions(session: AmplifierSession, prompt: str) -> N await context.add_message(msg_dict) -def _create_prompt_session(get_active_mode: Callable | None = None) -> PromptSession: +def _create_prompt_session( + get_active_mode: Callable | None = None, + bottom_toolbar: Callable | None = None, +) -> PromptSession: """Create configured PromptSession for REPL. Provides: @@ -1327,10 +1330,12 @@ def _create_prompt_session(get_active_mode: Callable | None = None) -> PromptSes - Green prompt styling matching Rich console - History search with Ctrl-R - Multi-line input with Ctrl-J + - Optional bottom toolbar (e.g. status bar with mode, model, session info) - Graceful fallback to in-memory history on errors Args: get_active_mode: Optional callable that returns the current active mode name + bottom_toolbar: Optional callable returning toolbar text (re-evaluated each render) Returns: Configured PromptSession instance @@ -1391,6 +1396,7 @@ def get_prompt(): multiline=True, # Enable multi-line display prompt_continuation=" ", # Two spaces for alignment (cleaner than "... ") enable_history_search=True, # Enables Ctrl-R + bottom_toolbar=bottom_toolbar, ) @@ -1450,6 +1456,24 @@ async def interactive_chat( register_incremental_save(session, store, actual_session_id, bundle_name, config) + # Inject CLI-specific formatting policy (new sessions only — avoid duplicates on resume). + # Shell commands must be single-line for reliable terminal copy-paste. + if not session_config.is_resume: + cli_context = session.coordinator.get("context") + if cli_context and hasattr(cli_context, "add_message"): + await cli_context.add_message( + { + "role": "developer", + "content": ( + "Shell command formatting: when providing shell commands for " + "the user to run, always emit each command as a single line. " + "Never use backslash line continuations, heredocs, or multi-line " + "constructs. If a command is long, that is acceptable — a long " + "single line is easier to copy from a terminal than a multi-line one." + ), + } + ) + # Show banner only for NEW sessions (resume shows banner via history display in commands/session.py) if not session_config.is_resume: config_summary = get_effective_config_summary(config, bundle_name) @@ -1463,13 +1487,6 @@ async def interactive_chat( ) ) - # Create prompt session for history and advanced editing - prompt_session = _create_prompt_session( - get_active_mode=lambda: command_processor.session.coordinator.session_state.get( - "active_mode" - ) - ) - # Helper to extract model name from config def _extract_model_name() -> str: if isinstance(config.get("providers"), list) and config["providers"]: @@ -1481,6 +1498,47 @@ def _extract_model_name() -> str: ) return "unknown" + # Persistent footer: [mode] session | model | ~/cwd (centered). + # Re-evaluated by prompt_toolkit on every keypress / redraw. + import html as _html + import shutil as _shutil + + model_name = _extract_model_name() + short_session_id = actual_session_id[:8] + + def _format_footer(): + cols = _shutil.get_terminal_size().columns + mode = command_processor.session.coordinator.session_state.get("active_mode") + + cwd = str(Path.cwd()) + home = str(Path.home()) + short_cwd = "~" + cwd[len(home) :] if cwd.startswith(home) else cwd + + parts = [] + if mode: + parts.append(f"[{mode}]") + parts.append(_html.escape(short_session_id)) + parts.append(_html.escape(model_name)) + parts.append(_html.escape(short_cwd)) + content = " | ".join(parts) + + # Center within the terminal width + content_len = ( + len(short_session_id) + len(model_name) + len(short_cwd) + 3 * len(" | ") + ) + if mode: + content_len += len(f"[{mode}]") + len(" | ") + pad = max(0, (cols - content_len) // 2) + return HTML(" " * pad + content) + + # Create prompt session for history and advanced editing + prompt_session = _create_prompt_session( + get_active_mode=lambda: command_processor.session.coordinator.session_state.get( + "active_mode" + ), + bottom_toolbar=_format_footer, + ) + # Helper to save session after each turn async def _save_session(): context = session.coordinator.get("context") diff --git a/amplifier_app_cli/ui/message_renderer.py b/amplifier_app_cli/ui/message_renderer.py index 1b9734d2..ecfae64a 100644 --- a/amplifier_app_cli/ui/message_renderer.py +++ b/amplifier_app_cli/ui/message_renderer.py @@ -6,12 +6,101 @@ Zero duplication: All message rendering goes through these functions. """ +import re + +from markdown_it import MarkdownIt from rich.console import Console from ..console import Markdown +_SHELL_LEXERS = frozenset({"bash", "sh", "shell", "zsh", "fish", "console"}) + +# Shared markdown-it parser for extracting code fence tokens. +# This correctly handles backticks inside fenced blocks (unlike regex). +_md_parser = MarkdownIt() + +# Fixed-width rule (72 chars) for command block framing. +_RULE_WIDTH = 72 +_RULE = "\u2500" * _RULE_WIDTH # ────────... +_SCISSORS = "\u2702" # ✂ + + +def _print_framed_command(code: str, console: Console) -> None: + """Print a shell command with visual framing for copy-paste. + + Renders: + ───── ✂ Copy & Run ────────────────────────────────────── + + + + ───────────────────────────────────────────────────────── + + The command is plain text with soft_wrap so the terminal handles visual + wrapping. Triple-click copies the full command as one logical line. + """ + # Top rule with scissors label + label = f" {_SCISSORS} Copy & Run " + top_rule = "\u2500" * 5 + label + "\u2500" * max(1, _RULE_WIDTH - 5 - len(label)) + console.print(f"\n[dim]{top_rule}[/dim]") + + # Command — plain text, single logical line, no Rich highlighting + console.print() + console.print(code, soft_wrap=True, highlight=False) + console.print() + + # Bottom rule + console.print(f"[dim]{_RULE}[/dim]") + + +def _render_content_with_copyable_commands(content: str, console: Console) -> None: + """Render markdown, framing shell code blocks for copy-paste. + + Uses markdown-it-py to parse the AST — this correctly identifies code fence + boundaries even when the command content contains backticks (e.g. commands + that generate markdown with echo '```'). Regex fence matching breaks on + these cases. + + Shell code blocks get visual framing (scissors rule + plain text + bottom + rule). Non-shell code blocks render normally through Rich Markdown. + """ + tokens = _md_parser.parse(content) + lines = content.split("\n") + + # Collect shell fence tokens with their line ranges + shell_fences: list[tuple[int, int, str]] = [] + for token in tokens: + if token.type == "fence" and token.map: + lang = ((token.info or "").split() or [""])[0] + if lang in _SHELL_LEXERS: + shell_fences.append((token.map[0], token.map[1], token.content)) + + if not shell_fences: + # No shell blocks — render everything through Markdown + console.print(Markdown(content)) + return + + last_line = 0 + for start_line, end_line, code in shell_fences: + # Render any markdown content before this shell block + before = "\n".join(lines[last_line:start_line]) + if before.strip(): + console.print(Markdown(before)) + + # Collapse backslash continuations and frame the command + code = re.sub(r" *\\\n[ \t]*", " ", code).rstrip() + _print_framed_command(code, console) + + last_line = end_line + + # Render any remaining content after the last shell block + remaining = "\n".join(lines[last_line:]) + if remaining.strip(): + console.print(Markdown(remaining)) + -def render_message(message: dict, console: Console, *, show_thinking: bool = False) -> None: +def render_message( + message: dict, console: Console, *, show_thinking: bool = False +) -> None: """Render a single message (user or assistant). Single source of truth for message formatting. Used by: @@ -39,9 +128,13 @@ def _render_user_message(message: dict, console: Console) -> None: console.print(f"\n[bold green]>[/bold green] {content}") -def _render_assistant_message(message: dict, console: Console, show_thinking: bool) -> None: +def _render_assistant_message( + message: dict, console: Console, show_thinking: bool +) -> None: """Render assistant message with green prefix and markdown.""" - text_blocks, thinking_blocks = _extract_content_blocks(message, show_thinking=show_thinking) + text_blocks, thinking_blocks = _extract_content_blocks( + message, show_thinking=show_thinking + ) # Skip rendering if message is empty (tool-only messages) if not text_blocks and not thinking_blocks: @@ -49,16 +142,20 @@ def _render_assistant_message(message: dict, console: Console, show_thinking: bo console.print("\n[bold green]Amplifier:[/bold green]") - # Render text blocks with default styling + # Render text blocks — shell code blocks get framing for copy-paste. + # _render_content_with_copyable_commands parses the AST once and + # short-circuits to plain Markdown when no shell fences are found. if text_blocks: - console.print(Markdown("\n".join(text_blocks))) + _render_content_with_copyable_commands("\n".join(text_blocks), console) # Render thinking blocks with dim styling for thinking in thinking_blocks: - console.print(Markdown(f"\n💭 **Thinking:**\n{thinking}", style="dim")) + console.print(Markdown(f"\n\U0001f4ad **Thinking:**\n{thinking}", style="dim")) -def _extract_content_blocks(message: dict, *, show_thinking: bool = False) -> tuple[list[str], list[str]]: +def _extract_content_blocks( + message: dict, *, show_thinking: bool = False +) -> tuple[list[str], list[str]]: """Extract text and thinking blocks separately from message content. Handles multiple content formats: @@ -123,7 +220,7 @@ def _extract_content(message: dict, *, show_thinking: bool = False) -> str: text_parts.append(block.get("text", "")) elif block.get("type") == "thinking" and show_thinking: thinking = block.get("thinking", "") - text_parts.append(f"\n[dim]💭 Thinking: {thinking}[/dim]\n") + text_parts.append(f"\n[dim]\U0001f4ad Thinking: {thinking}[/dim]\n") return "\n".join(text_parts) # Fallback for unexpected formats diff --git a/uv.lock b/uv.lock index 6b149161..011dab57 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ dependencies = [ { name = "httpx", extra = ["socks"] }, { name = "prompt-toolkit" }, { name = "pydantic" }, + { name = "pygments" }, { name = "pyyaml" }, { name = "rich" }, ] @@ -25,12 +26,13 @@ dev = [ [package.metadata] requires-dist = [ - { name = "amplifier-core", git = "https://github.com/microsoft/amplifier-core?branch=main" }, + { name = "amplifier-core" }, { name = "amplifier-foundation", git = "https://github.com/microsoft/amplifier-foundation?branch=main" }, { name = "click", specifier = ">=8.1.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.28.1" }, { name = "prompt-toolkit", specifier = ">=3.0.52" }, { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pygments", specifier = ">=2.13.0" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=13.0.0" }, ]