Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 66 additions & 8 deletions amplifier_app_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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)
Expand All @@ -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"]:
Expand All @@ -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"<ansicyan>[{mode}]</ansicyan>")
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")
Expand Down
113 changes: 105 additions & 8 deletions amplifier_app_cli/ui/message_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────
<blank line>
<command as single logical line, soft_wrap=True>
<blank line>
─────────────────────────────────────────────────────────

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:
Expand Down Expand Up @@ -39,26 +128,34 @@ 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:
return

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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.