Skip to content
Draft
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
57 changes: 57 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-groups --locked
- run: uv run ruff check amplifier_app_cli tests

types:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-groups --locked
- run: uv run pyright

tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-groups --locked
- run: uv run pytest -q

# PTY integration tests fork a real pty child process and probe termios
# state, so they need a Linux runner and run separately from the default
# suite (deselected via the "integration" marker in pyproject.toml).
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"
enable-cache: true
- run: uv sync --all-groups --locked
- run: sudo apt-get update && sudo apt-get install --yes tmux
- run: tmux -V
- run: uv run pytest -m integration -q
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ next-steps.md
# Working folders
ai_working/tmp
tests/recipes/DECISIONS.md

# Project-scope settings — personal preferences for TUI startup mode/posture
.amplifier/
133 changes: 133 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Agent guide — amplifier-app-cli

Reference CLI for the Amplifier platform. Source lives in `amplifier_app_cli/`,
tests in `tests/`, documentation in `docs/`. Read files before editing them;
prefer changing existing modules over creating new ones.

## Verify loop (run before claiming done)

| Command | What | Typical runtime |
|---------|------|-----------------|
| `uv run ruff check amplifier_app_cli tests` | lint | <1s |
| `uv run pyright` | types (basic mode, `amplifier_app_cli/` only) | ~4.5s |
| `uv run pytest` | default suite (~1,800 tests; integration deselected) | ~31s |
| `uv run pytest -m integration` | 13 PTY tests (fork a real pty, probe termios) | seconds, needs a real POSIX terminal |

Shortcuts: `just check` runs the first three; `just check-full` adds the
integration marker; `just fmt` formats. See `justfile`.

While iterating, run the focused test file(s) for what you touched
(`uv run pytest tests/test_<area>.py -q`), then the full suite before finishing.

## Module map

Entry flow for the interactive TUI:

```
main.py (click group, thin compat adapters)
└─ runtime/interactive_resume_loop.py in-process resume switching
└─ runtime/interactive_host.py assembles one interactive session
├─ runtime/interactive_*.py input routing, turn runner, cleanup,
│ resources, persistence, repair
└─ ui/layered_repl*.py full-screen prompt_toolkit app
├─ ui/transcript_blocks.py typed block rendering (Rich)
└─ ui/footer.py persistent two-zone footer
```

- `amplifier_app_cli/runtime/` — session lifecycle: host, turn execution,
interrupts, persistence, transcript repair, spawn/resume, config resolution.
No rendering decisions here.
- `amplifier_app_cli/ui/` — presentation and interaction: layered REPL
surfaces, transcript blocks, footer, approval, palette, agent lanes, slash
command processing (`command_processor.py` + `command_*.py` mixins).
- `amplifier_app_cli/commands/` — non-interactive click subcommands
(provider, bundle, init, session, …).
- Single-shot path: `main.py execute_single` → `runtime/single_execution.py`.

`docs/designs/interactive-tui-architecture.md` has the full picture with
diagrams. `docs/MIGRATION-main-decomposition.md` maps the old monolithic
`main.py` (~3,500 lines) to the current modules.

## Presentation source of truth

`docs/designs/tui-v3-cohesive.md` is the approved presentation spec (colors,
glyphs, labels, layout, hints). Theme tokens live in
`amplifier_app_cli/ui/layered_repl_style.py` (`TOKENS` / `THEMES`) — never
hardcode hex values in rendering surfaces. Mechanisms (trust postures,
steering, evidence, ledger) are governed by
`docs/decisions/ADR-0005-interaction-modes-and-trust-postures.md` and
`docs/decisions/ADR-0006-full-screen-pinned-interactive-shell.md`.

TUI interaction realities worth knowing (spec sections 3, 4, 6, 9):

- shift+enter (queue a next-turn message mid-turn) works natively on kitty,
WezTerm, foot, ghostty, iTerm2 3.5+, and recent xterm via progressive
keyboard enhancement (kitty keyboard protocol + xterm modifyOtherKeys, see
`amplifier_app_cli/ui/keyboard_protocol.py`); alt+enter is the fallback on
legacy terminals. The footer's running hint advertises shift+enter, unless
the startup capability probe (`amplifier_app_cli/ui/terminal_probe.py`)
finds no kitty keyboard protocol support, in which case it advertises
alt+enter.
- Keybindings live in one table (`amplifier_app_cli/ui/key_bindings_table.py`)
that drives both dispatch (`layered_repl_keys.py`) and footer hint labels
(`footer.py`), so keys and hints cannot drift. Notable chords: ctrl-g (edit
draft in `$VISUAL`/`$EDITOR`), alt+up (recall the newest queued message),
y/a/d (approval decide), ctrl-a (approval full detail).
- Transcript click affordances are single-click, no-drag actions with keyboard
equivalents: expand/collapse tool output (ctrl-o), open rewind at a turn
rule (ctrl-r), reveal evidence for an answer (ctrl-e). Drag/selection stays
with the terminal.
- The footer is responsive: the `mode <id>` prefix shows at >=100 columns
(the trust dial abbreviates first); below that the prefix is dropped.

## Golden tests and regeneration (readable snapshots)

`tests/test_transcript_golden_widths.py` and
`tests/test_footer_golden_widths.py` pin the exact rendered screens as plain
text files under `tests/goldens/` — transcript blocks at widths 40/80/120
plus a full-sequence gallery at 40/80/97/120 (`transcript/gallery_<w>.txt`),
and the idle footer at 80/120/198 (`footer/idle_<w>.txt`). A failure prints a
unified diff of the screen; read it as a UI diff (before/after screens), and
review checked-in golden diffs in PRs the same way. A second layer of
semantic marker assertions (`GOLDEN_MARKERS`) guards meaning independently of
exact layout.

Snapshot hygiene: golden inputs are deterministic (fixed `Telemetry` values,
fixed session ids); environment-dependent artifacts (project/tmp paths, OSC 8
hyperlinks, trailing padding) are canonicalized by
`tests/helpers.normalize_for_golden` — route every golden write and read
through it (`helpers.write_golden` / `helpers.assert_matches_golden`). Never
hand-edit files under `tests/goldens/`.

```bash
uv run python tests/regen_goldens.py # dry run: list pending golden changes (exit 1 if any)
uv run python tests/regen_goldens.py --write # rewrite tests/goldens/**/*.txt (prunes stale files)
# or: just regen-goldens / just goldens-status
```

**Policy:** any change to user-visible rendering must add or update a golden
in the same commit; review golden diffs as UI diffs. An *intentional*
presentation change also updates `docs/designs/tui-v3-cohesive.md` in that
commit. Never regen to make an *unintended* diff pass — that is a
regression, not a regen.

## Invariant suites (boundary tests)

These encode architectural contracts; if one fails, fix your change, not the
test:

- `tests/test_private_api_boundaries.py` — no cross-module private-API reach-ins
- `tests/test_main_entrypoint_boundary.py` — `main.py` stays a thin adapter
- `tests/test_command_processor_boundary.py` — command processor facade contract
- `tests/test_layered_repl_boundary.py` — layered REPL surface contract
- `tests/test_runtime_config_boundaries.py` — runtime config resolution seams
- `tests/test_paste_execution_boundary.py` — paste handling vs execution split

## Conventions

- `uv` for everything (`uv sync --all-groups`, `uv run …`). Python 3.11+.
- Keep public APIs typed and modules focused; avoid files over 500 lines when
practical.
- Never commit credentials, API keys, `.env` files, or other secrets.
- Validate input at system boundaries and sanitize filesystem paths.
- Make only the changes the task requires; preserve unrelated worktree changes.
28 changes: 18 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,15 +305,21 @@ manual source overrides are required for the built-in providers.

```bash
cd amplifier-app-cli
uv pip install -e .
uv sync --all-groups
uv run pytest
uv run ruff check amplifier_app_cli tests
uv run pyright
```

### Project Structure

```
amplifier_app_cli/
├── commands/ # CLI command implementations (provider, bundle, init, logs, setup)
├── commands/ # CLI command implementations (provider, bundle, init, session, …)
├── runtime/ # Session lifecycle: interactive host, turn execution,
│ # interrupts, persistence, transcript repair, spawn/resume
├── ui/ # Interactive TUI: layered REPL surfaces, transcript blocks,
│ # footer, approval, palette, slash-command processing
├── data/
│ └── context/ # Bundled context files
├── lib/ # Shared libraries
Expand All @@ -327,14 +333,15 @@ amplifier_app_cli/
├── session_store.py # Session persistence (transcript, metadata, state)
├── session_spawner.py # Agent delegation (spawn and resume sub-sessions)
├── agent_config.py # Agent configuration utilities
└── main.py # CLI entry point

toolkit/ # Standalone scenario tool utilities (at repo root)
├── utilities/ # Structural utilities (file ops, progress, validation)
├── examples/ # Example tools (tutorial_analyzer)
└── templates/ # Tool templates
└── main.py # CLI entry point (thin click group; delegates to runtime/)
```

Interactive entry flow: `main.py` → `runtime/interactive_host.py` →
`ui/layered_repl*.py`, with rendering in `ui/transcript_blocks.py` and
`ui/footer.py`. See [Interactive TUI Architecture](docs/designs/interactive-tui-architecture.md)
for diagrams, and the repo `justfile` (`just check`, `just check-full`,
`just fmt`, `just regen-goldens`) for the standard verification tasks.

**Note**: Core functionality provided by libraries:
- `amplifier-foundation` - Bundle loading and composition (primary)
- `amplifier-config` - Settings management
Expand All @@ -345,14 +352,15 @@ toolkit/ # Standalone scenario tool utilities (at repo root)
- [Agent Delegation](docs/AGENT_DELEGATION_IMPLEMENTATION.md) - Sub-session spawning and resumption
- [Context Loading](docs/CONTEXT_LOADING.md) - @mention system implementation
- [Interactive Mode](docs/INTERACTIVE_MODE.md) - REPL and slash commands
- [Interactive TUI Architecture](docs/designs/interactive-tui-architecture.md) - runtime/ vs ui/ split, input→turn→render flow
- [TUI Presentation Spec](docs/designs/tui-v3-cohesive.md) - approved presentation source of truth (theme, glyphs, layout)
- [main.py Decomposition Map](docs/MIGRATION-main-decomposition.md) - old monolith → current modules
- [Architectural Decisions](docs/decisions/) - ADRs for major design choices

**Authoritative Guides** (external, maintained in library repos):
- **→ [Bundle Guide](https://github.com/microsoft/amplifier-foundation/blob/main/docs/BUNDLE_GUIDE.md)** - Creating and managing bundles
- **→ [User Onboarding](https://github.com/microsoft/amplifier/blob/main/docs/USER_ONBOARDING.md)** - Complete user guide and reference

**Toolkit** (for building sophisticated tools):

## Contributing

> [!NOTE]
Expand Down
46 changes: 45 additions & 1 deletion amplifier_app_cli/approval_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import logging
from typing import Any

from amplifier_core import ApprovalRequest
from amplifier_core import ApprovalResponse
Expand All @@ -12,6 +13,11 @@
from rich.prompt import Confirm

from .stdin_arbiter import StdinArbiter
from .ui.inline_approval import STANDARD_APPROVAL_OPTIONS
from .ui.inline_approval import ApprovalDetail
from .ui.inline_approval import decision_for_choice
from .ui.inline_approval import option_labels
from .ui.inline_approval import stage_approval_detail

logger = logging.getLogger(__name__)

Expand All @@ -23,15 +29,23 @@ class CLIApprovalProvider:
Implements ApprovalProvider protocol for CLI environments.
"""

def __init__(self, console: Console, arbiter: StdinArbiter | None = None):
def __init__(
self,
console: Console,
approval_system: Any | None = None,
*,
arbiter: StdinArbiter | None = None,
):
"""
Initialize CLI approval provider.

Args:
console: Rich console for output
approval_system: Optional layered UI approval system
arbiter: Optional stdin arbiter for coordinating with steering reader
"""
self.console = console
self.approval_system = approval_system
self._arbiter = arbiter

async def request_approval(self, request: ApprovalRequest) -> ApprovalResponse:
Expand Down Expand Up @@ -59,6 +73,25 @@ async def request_approval(self, request: ApprovalRequest) -> ApprovalResponse:

async def _do_request_approval(self, request: ApprovalRequest) -> ApprovalResponse:
"""Inner implementation of request_approval (wrapped by arbiter claim)."""
if self.approval_system is not None:
timeout = request.timeout if request.timeout is not None else 300.0
prompt = f"Allow {request.tool_name}: {request.action}?"
# Keep the full payload available to the inline surface (ctrl-a
# full-detail view) beyond the bar's bounded summary.
stage_approval_detail(prompt, _approval_detail(prompt, request))
choice = await self.approval_system.request_approval(
prompt,
list(option_labels(STANDARD_APPROVAL_OPTIONS)),
timeout,
"deny",
)
decision = decision_for_choice(STANDARD_APPROVAL_OPTIONS, choice)
approved = decision != "deny"
return ApprovalResponse(
approved=approved,
reason="User approved" if approved else "User denied",
)

# Build rich panel with request details
risk_color = self._get_risk_color(request.risk_level)

Expand Down Expand Up @@ -147,3 +180,14 @@ async def _get_user_input(self) -> bool:
None, lambda: Confirm.ask("\nApprove this action?", default=False)
)
return result


def _approval_detail(prompt: str, request: ApprovalRequest) -> ApprovalDetail:
"""Full request payload (tool, action, risk, details) for ctrl-a."""
fields: list[tuple[str, str]] = [
("tool", request.tool_name),
("action", request.action),
("risk", request.risk_level),
]
fields.extend((str(key), str(value)) for key, value in request.details.items())
return ApprovalDetail(prompt=prompt, fields=tuple(fields))
5 changes: 3 additions & 2 deletions amplifier_app_cli/commands/allowed_dirs.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from ..paths import create_config_manager
from ..paths import get_effective_scope
from ..paths import ScopeNotAvailableError
from ..paths import ScopeType
from ..utils.error_format import escape_markup

console = Console()
Expand Down Expand Up @@ -114,7 +115,7 @@ def add_dir(path: str, scope_flag: str | None):
config_manager = create_config_manager()
try:
scope, was_fallback = get_effective_scope(
cast(Scope, scope_flag) if scope_flag else None,
cast(ScopeType, scope_flag) if scope_flag else None,
config_manager,
default_scope="global", # Default to global for CLI
)
Expand Down Expand Up @@ -161,7 +162,7 @@ def remove_dir(path: str, scope_flag: str | None):
config_manager = create_config_manager()
try:
scope, was_fallback = get_effective_scope(
cast(Scope, scope_flag) if scope_flag else None,
cast(ScopeType, scope_flag) if scope_flag else None,
config_manager,
default_scope="global", # Default to global for CLI
)
Expand Down
4 changes: 2 additions & 2 deletions amplifier_app_cli/commands/bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ def bundle_show(name: str, compact: bool, detailed: bool, fmt: str):

# Build include chains from the registry's disk graph.
try:
from amplifier_foundation.configurator._inspector import walk_include_chains
from amplifier_foundation.configurator import walk_include_chains

registry_dict = dict(registry._registry)
include_chains = walk_include_chains(name, registry_dict)
Expand All @@ -490,7 +490,7 @@ def bundle_show(name: str, compact: bool, detailed: bool, fmt: str):
bundle_item: dict[str, Any] = {
"name": bundle_obj.name,
"enabled": True, # bundles are available/loadable — active-ness shown via active: yes/no
"source_uri": bundle_obj.uri if hasattr(bundle_obj, "uri") else None,
"source_uri": getattr(bundle_obj, "uri", None),
"include_paths": [
[
{
Expand Down
Loading