d72993d908
CI / lint (pull_request) Successful in 29s
CI / typecheck (pull_request) Successful in 50s
CI / security (pull_request) Successful in 1m3s
CI / quality (pull_request) Successful in 33s
CI / build (pull_request) Successful in 23s
CI / helm (pull_request) Successful in 24s
CI / unit_tests (pull_request) Failing after 7m12s
CI / docker (pull_request) Has been skipped
CI / e2e_tests (pull_request) Successful in 17m51s
CI / integration_tests (pull_request) Failing after 23m31s
CI / coverage (pull_request) Successful in 10m44s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 56m49s
- docs/api/tui.md: Document ShellDangerLevel, DangerousPattern, DEFAULT_PATTERNS, DangerousPatternDetector, ShellSafetyService, and SafetyCheckResult with full API reference, parameter tables, and usage examples - docs/architecture.md: Add Invariant Reconciliation section covering the builtin/invariant-reconciliation actor, four-scope algorithm, failure behaviour, and DI registration - README.md: Add Invariant Reconciliation, TUI shell danger detection, and UKO provenance tracking to the Highlights section ISSUES CLOSED: #3377
451 lines
13 KiB
Markdown
451 lines
13 KiB
Markdown
# `cleveragents.tui` — Interactive Terminal UI
|
|
|
|
The `tui` package provides the full-screen Textual-based terminal user interface.
|
|
It requires the optional `cleveragents[tui]` extra.
|
|
|
|
See [ADR-044](../adr/ADR-044-tui-architecture-and-framework.md) (TUI framework),
|
|
[ADR-045](../adr/ADR-045-tui-persona-system.md) (persona system), and
|
|
[ADR-046](../adr/ADR-046-tui-reference-and-command-system.md) (reference and command system)
|
|
for design rationale.
|
|
|
|
---
|
|
|
|
## Launching the TUI
|
|
|
|
```bash
|
|
pip install "cleveragents[tui]"
|
|
agents tui
|
|
```
|
|
|
|
---
|
|
|
|
## First-Run Experience
|
|
|
|
### `is_first_run(registry: PersonaRegistry) → bool`
|
|
|
|
Returns `True` when no personas are configured — used by `app.on_mount()` to
|
|
decide whether to show the actor selection overlay.
|
|
|
|
### `create_default_persona_for_actor(registry: PersonaRegistry, actor: str) → None`
|
|
|
|
Creates and persists a `"default"` persona bound to `actor` after the user
|
|
completes the first-run selection flow.
|
|
|
|
### `ActorSelectionOverlay`
|
|
|
|
```python
|
|
from cleveragents.tui.widgets import ActorSelectionOverlay
|
|
```
|
|
|
|
A centred Textual widget displayed on first launch.
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `show()` | Make the overlay visible |
|
|
| `hide()` | Dismiss the overlay |
|
|
| `move_up()` | Move selection cursor up (wraps) |
|
|
| `move_down()` | Move selection cursor down (wraps) |
|
|
| `set_search(query: str)` | Apply fuzzy filter to actor list |
|
|
| `confirm() → str` | Confirm selection; returns actor name and hides overlay |
|
|
| `render_actor_selection() → str` | Pure rendering function (testable without Textual) |
|
|
|
|
Default actor list (in display order):
|
|
|
|
| Actor | Notes |
|
|
|-------|-------|
|
|
| `anthropic/claude-4-sonnet` | Recommended |
|
|
| `anthropic/claude-4-opus` | |
|
|
| `openai/gpt-4o` | |
|
|
| `openai/o3` | |
|
|
| `google/gemini-2` | |
|
|
|
|
**Key bindings inside the overlay:**
|
|
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| `j` / `↓` | Move down |
|
|
| `k` / `↑` | Move up |
|
|
| `/` | Enter fuzzy search |
|
|
| `Enter` | Confirm selection |
|
|
|
|
---
|
|
|
|
## Persona System
|
|
|
|
Personas are YAML files stored in `~/.config/cleveragents/personas/`. Each
|
|
persona binds an actor, optional argument presets, and scope references to a
|
|
named identity.
|
|
|
|
### `PersonaRegistry`
|
|
|
|
```python
|
|
from cleveragents.tui.persona import PersonaRegistry
|
|
|
|
registry = PersonaRegistry()
|
|
registry.load() # load all YAML files from config dir
|
|
persona = registry.get("default") # retrieve by name
|
|
registry.save(persona) # persist changes
|
|
registry.ensure_default(actor="openai/gpt-4o") # create default if absent
|
|
```
|
|
|
|
### `Persona`
|
|
|
|
```python
|
|
@dataclass
|
|
class Persona:
|
|
name: str
|
|
actor: str
|
|
presets: list[ArgumentPreset]
|
|
scope_refs: list[str]
|
|
```
|
|
|
|
---
|
|
|
|
## Input Mode Routing
|
|
|
|
The prompt auto-detects three input modes from the first character:
|
|
|
|
| First character | Mode | Handler |
|
|
|-----------------|------|---------|
|
|
| (none / letter) | Normal | Message + `@reference` expansion |
|
|
| `/` | Command | Slash command overlay |
|
|
| `!` | Shell | Subprocess passthrough |
|
|
|
|
### `InputModeRouter`
|
|
|
|
```python
|
|
from cleveragents.tui.routing import InputModeRouter
|
|
|
|
router = InputModeRouter(container)
|
|
await router.dispatch(input_text, session_id)
|
|
```
|
|
|
|
---
|
|
|
|
## Slash Commands
|
|
|
|
67 slash commands across 14 groups are exposed via `SlashCommandOverlay`.
|
|
|
|
```python
|
|
from cleveragents.tui.commands import SLASH_COMMAND_SPECS
|
|
|
|
# SLASH_COMMAND_SPECS: dict[str, list[SlashCommandSpec]]
|
|
# Keys are group names; values are lists of command specs.
|
|
```
|
|
|
|
**Groups:** Session, Persona, Scope, Plan, Project, Actor, Resource, Config,
|
|
Tool, Skill, Invariant, Profile, Context, Utility.
|
|
|
|
### Session commands (via `TuiCommandRouter`)
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `/session:export [--format json\|md] [path]` | Export session to JSON or Markdown |
|
|
| `/session:import <path>` | Import a session from a JSON file |
|
|
|
|
```python
|
|
# Export as Markdown transcript
|
|
/session:export --format md ~/my-session.md
|
|
|
|
# Export as canonical JSON (default)
|
|
/session:export ~/my-session.json
|
|
|
|
# Import a previously exported session
|
|
/session:import ~/my-session.json
|
|
```
|
|
|
|
---
|
|
|
|
## Session Export / Import
|
|
|
|
### `Session.as_export_markdown() → str`
|
|
|
|
Domain method on `Session` that renders a human-readable Markdown transcript.
|
|
The output is **lossy** (for sharing/documentation) and cannot be re-imported.
|
|
|
|
```python
|
|
from cleveragents.domain.models.core.session import Session
|
|
|
|
session: Session = ...
|
|
md = session.as_export_markdown()
|
|
# Returns a Markdown string with:
|
|
# - Header block: session ID, actor, created_at, message count
|
|
# - Message history: role | timestamp | content
|
|
# - Linked plan references
|
|
```
|
|
|
|
### CLI
|
|
|
|
```bash
|
|
# Export as canonical JSON (importable)
|
|
agents session export --session-id <ID> --output session.json
|
|
|
|
# Export as Markdown transcript (human-readable, not importable)
|
|
agents session export --session-id <ID> --output session.md --format md
|
|
|
|
# Import from JSON
|
|
agents session import --input session.json
|
|
```
|
|
|
|
---
|
|
|
|
## Widgets
|
|
|
|
### `ThoughtBlockWidget`
|
|
|
|
Renders actor reasoning traces inline in the conversation stream.
|
|
|
|
```python
|
|
from cleveragents.tui.widgets import ThoughtBlockWidget
|
|
```
|
|
|
|
- Collapsed by default; press `Space` to expand/collapse
|
|
- Muted styling distinguishes thought blocks from regular messages
|
|
- Backed by `ThoughtBlock` domain model with configurable `max_lines` (default: 10)
|
|
|
|
### `PermissionQuestionWidget`
|
|
|
|
Inline permission question widget rendered directly in the conversation stream
|
|
for single-file permission requests. For multi-file operations the full
|
|
`PermissionsScreen` is pushed instead.
|
|
|
|
```python
|
|
from cleveragents.tui.widgets import PermissionQuestionWidget
|
|
from cleveragents.domain.models.core.inline_permission_question import (
|
|
InlinePermissionQuestion,
|
|
PermissionDecision,
|
|
)
|
|
|
|
widget = PermissionQuestionWidget(question)
|
|
event = widget.handle_key("a") # returns PermissionDecisionEvent or None
|
|
```
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `move_up()` | Move selection cursor up (wraps) |
|
|
| `move_down()` | Move selection cursor down (wraps) |
|
|
| `handle_key(key: str) → PermissionDecisionEvent \| None` | Process a key press; returns a decision event when resolved |
|
|
|
|
**Key bindings:**
|
|
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| `a` | Allow once |
|
|
| `A` | Allow always (this session) |
|
|
| `r` | Reject once |
|
|
| `R` | Reject always (this session) |
|
|
| `↑` / `↓` | Navigate options |
|
|
| `Enter` | Confirm highlighted option |
|
|
| `v` | Open full `PermissionsScreen` with diff view |
|
|
|
|
**`PermissionDecisionEvent`** — emitted when the user makes a decision:
|
|
|
|
```python
|
|
@dataclass
|
|
class PermissionDecisionEvent:
|
|
question: InlinePermissionQuestion
|
|
decision: PermissionDecision
|
|
```
|
|
|
|
**`render_permission_question(question, selected_index=0, *, show_diff=False) → str`** — pure rendering helper (testable without Textual).
|
|
|
|
---
|
|
|
|
### `PermissionsScreen`
|
|
|
|
Full-screen overlay for tool permission requests.
|
|
|
|
```python
|
|
from cleveragents.tui.screens import PermissionsScreen
|
|
```
|
|
|
|
| Key | Action |
|
|
|-----|--------|
|
|
| `a` | Allow once |
|
|
| `A` | Allow always |
|
|
| `r` | Reject once |
|
|
| `R` | Reject always |
|
|
| `d` | Cycle diff display mode (unified → side-by-side → context) |
|
|
|
|
---
|
|
|
|
## Shell Danger Detection
|
|
|
|
**Module:** `cleveragents.tui.shell_safety`
|
|
|
|
The TUI shell mode (`!` prefix) checks commands against a configurable pattern
|
|
registry before execution. Commands that match a dangerous pattern surface a
|
|
warning overlay; commands at or above the configured `block_level` are blocked
|
|
automatically unless a `warn_callback` is provided.
|
|
|
|
### `ShellDangerLevel`
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety import ShellDangerLevel
|
|
```
|
|
|
|
`IntEnum` with four severity levels (ordered least to most severe):
|
|
|
|
| Level | Value | Description |
|
|
|-------|-------|-------------|
|
|
| `LOW` | 1 | Minor risk — generally recoverable (e.g. `chmod 777` on a single file) |
|
|
| `MEDIUM` | 2 | Moderate risk — can cause data loss or security exposure (e.g. `sudo rm`, `wget \| sh`) |
|
|
| `HIGH` | 3 | High risk — significant, hard-to-reverse damage (e.g. `dd if=`, `mkfs`) |
|
|
| `CRITICAL` | 4 | Critical risk — can destroy the system or create a fork bomb (e.g. `rm -rf /`, `:(){ :\|:& };:`) |
|
|
|
|
Because `ShellDangerLevel` is an `IntEnum`, numeric comparisons work
|
|
naturally: `level >= ShellDangerLevel.HIGH`.
|
|
|
|
---
|
|
|
|
### `DangerousPattern`
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety import DangerousPattern
|
|
|
|
name="rm_rf_root",
|
|
pattern=r"rm\s+-[rRfF]*\s+/",
|
|
level=ShellDangerLevel.CRITICAL,
|
|
description="Recursively deletes the root filesystem.",
|
|
)
|
|
```
|
|
|
|
Immutable frozen dataclass describing a single dangerous shell pattern.
|
|
|
|
| Attribute | Type | Description |
|
|
|-----------|------|-------------|
|
|
| `name` | `str` | Short human-readable identifier |
|
|
| `pattern` | `str` | Regex matched against the command text |
|
|
| `level` | `ShellDangerLevel` | Severity classification |
|
|
| `description` | `str` | Human-readable explanation of the risk |
|
|
| `case_sensitive` | `bool` | When `True`, regex is compiled without `re.IGNORECASE` (default: `False`) |
|
|
|
|
**`matches(command: str) → bool`** — returns `True` if `command` matches the compiled pattern.
|
|
|
|
---
|
|
|
|
### Built-in Patterns (`DEFAULT_PATTERNS`)
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety.pattern_registry import DEFAULT_PATTERNS
|
|
```
|
|
|
|
The default pattern set covers the most common dangerous shell operations:
|
|
|
|
| Name | Level | Trigger |
|
|
|------|-------|---------|
|
|
| `rm_rf_root` | CRITICAL | `rm -rf /` |
|
|
| `rm_rf_wildcard` | CRITICAL | `rm -rf /*` or similar |
|
|
| `fork_bomb` | CRITICAL | `:(){ :|:& };:` |
|
|
| `dd_if_device` | HIGH | `dd if=` |
|
|
| `mkfs` | HIGH | `mkfs` |
|
|
| `shred_device` | HIGH | `shred /dev/` or `shred --remove` |
|
|
| `chmod_777` | MEDIUM | `chmod 777` |
|
|
| `sudo_rm` | MEDIUM | `sudo rm` |
|
|
| `wget_pipe_sh` | MEDIUM | `wget … \| sh` |
|
|
| `curl_pipe_sh` | MEDIUM | `curl … \| sh` |
|
|
| `wget_pipe_bash` | MEDIUM | `wget … \| bash` |
|
|
| `curl_pipe_bash` | MEDIUM | `curl … \| bash` |
|
|
| `git_push_force` | LOW | `git push --force` |
|
|
| `chmod_recursive_permissive` | LOW | `chmod -R 6xx/7xx` |
|
|
|
|
---
|
|
|
|
### `DangerousPatternDetector`
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety import DangerousPatternDetector
|
|
|
|
detector = DangerousPatternDetector()
|
|
warning = detector.check_first("rm -rf /")
|
|
# → DangerousCommandWarning or None
|
|
```
|
|
|
|
Checks a command string against all registered patterns and returns the
|
|
first match as a `DangerousCommandWarning`, or `None` if the command is safe.
|
|
|
|
| Method | Description |
|
|
|--------|-------------|
|
|
| `check_first(command) → DangerousCommandWarning \| None` | Return the first matching warning |
|
|
| `check_all(command) → list[DangerousCommandWarning]` | Return all matching warnings |
|
|
| `add_pattern(pattern)` | Register an additional `DangerousPattern` |
|
|
|
|
---
|
|
|
|
### `ShellSafetyService`
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety import ShellSafetyService, ShellDangerLevel
|
|
|
|
service = ShellSafetyService(block_level=ShellDangerLevel.MEDIUM)
|
|
result = service.check_command("rm -rf /")
|
|
if not result.allowed:
|
|
print(result.warning.message)
|
|
```
|
|
|
|
Application service that checks shell commands before execution.
|
|
|
|
**Constructor parameters:**
|
|
|
|
| Parameter | Type | Default | Description |
|
|
|-----------|------|---------|-------------|
|
|
| `detector` | `DangerousPatternDetector \| None` | `None` | Custom detector; a default one is created when `None` |
|
|
| `block_level` | `ShellDangerLevel` | `MEDIUM` | Minimum level that triggers automatic blocking when no callback is provided |
|
|
| `warn_callback` | `Callable[[DangerousCommandWarning], bool] \| None` | `None` | Optional callback invoked for dangerous commands; return `True` to allow, `False` to block |
|
|
| `extra_patterns` | `list[DangerousPattern] \| None` | `None` | Additional patterns registered on top of the defaults |
|
|
|
|
**Methods:**
|
|
|
|
| Method | Returns | Description |
|
|
|--------|---------|-------------|
|
|
| `check_command(command)` | `SafetyCheckResult` | Check a command and return a structured result |
|
|
| `is_safe(command)` | `bool` | Convenience wrapper — returns `True` if the command passes |
|
|
|
|
**`SafetyCheckResult`** — import path and attributes:
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety.safety_service import SafetyCheckResult
|
|
```
|
|
|
|
| Attribute | Type | Description |
|
|
|-----------|------|-------------|
|
|
| `command` | `str` | The command that was checked |
|
|
| `warning` | `DangerousCommandWarning \| None` | Warning if a dangerous pattern matched, otherwise `None` |
|
|
| `allowed` | `bool` | `True` if the command is allowed to proceed |
|
|
|
|
**Example — custom block level and callback:**
|
|
|
|
```python
|
|
from cleveragents.tui.shell_safety import ShellSafetyService, ShellDangerLevel
|
|
|
|
def my_callback(warning):
|
|
# Show a UI prompt; return True to allow, False to block
|
|
return ask_user(f"⚠️ {warning.message}. Proceed?")
|
|
|
|
service = ShellSafetyService(
|
|
block_level=ShellDangerLevel.HIGH,
|
|
warn_callback=my_callback,
|
|
)
|
|
result = service.check_command("sudo rm /etc/hosts")
|
|
```
|
|
|
|
**Built-in pattern categories:**
|
|
|
|
- Destructive filesystem operations (`rm -rf`, `shred`, `wipe`)
|
|
- Privilege escalation (`sudo`, `su`, `chmod 777 /`)
|
|
- Network exfiltration (`curl \| sh`, `wget \| bash`, `nc` reverse shells)
|
|
- Fork bombs and resource exhaustion
|
|
- Disk-level writes (`dd if=`, `mkfs`)
|
|
|
|
---
|
|
|
|
## TUI State Persistence
|
|
|
|
The TUI persists minimal state to `~/.config/cleveragents/tui-state.yaml`:
|
|
|
|
```yaml
|
|
last_persona: "default"
|
|
```
|
|
|
|
This is loaded on startup to restore the last active persona.
|