- 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
13 KiB
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 (TUI framework), ADR-045 (persona system), and ADR-046 (reference and command system) for design rationale.
Launching the TUI
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
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
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
@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
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.
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 |
# 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.
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
# 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.
from cleveragents.tui.widgets import ThoughtBlockWidget
- Collapsed by default; press
Spaceto expand/collapse - Muted styling distinguishes thought blocks from regular messages
- Backed by
ThoughtBlockdomain model with configurablemax_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.
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:
@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.
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
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
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)
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
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
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:
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:
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,ncreverse 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:
last_persona: "default"
This is loaded on startup to restore the last active persona.