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" },
]