# LAIDA CLI TUI - Mejores Prácticas para Claude Code Output Integration
## Informe Técnico Completo Respetando Arquitectura Hexagonal

**Fecha**: 23 de Diciembre 2025  
**Arquitectura**: Hexagonal (Ports & Adapters)  
**Contexto**: Visualización de Claude Code output en TUI + Sanitización de rutas  
**Estado**: Especificación de Implementación  

---

## 📐 ARQUITECTURA HEXAGONAL - CONTEXTO

Tu proyecto ya sigue hexagonal:
```
DOMAIN (entidades puras)
   ↓
PORTS (interfaces abstractas)
   ↓
ADAPTERS (implementaciones concretas)
   ↓
SERVICES (orquestación de casos de uso)
   ↓
TUI (presentación)
```

Esta propuesta **respeta esa estructura exactamente**.

---

## 🎯 LAS 6 MEJORES PRÁCTICAS ESPECIFICADAS

### **BEST PRACTICE #1: Queue-Based Communication (Thread-Safe)**

#### Problema
```
call_from_thread() falla cuando timing es incorrecto
→ RuntimeError: must run in different thread from app
```

#### Solución Hexagonal
```
DOMAIN LAYER (domain/stream_output.py)
├── class StreamOutput(ValueObject)
│   ├── id: str (UUID)
│   ├── timestamp: datetime
│   ├── content: str (raw, sin sanitizar aún)
│   ├── source: StreamSource (enum: PTY | PIPE)
│   ├── is_buffered: bool
│   └── metadata: dict (para debug)
```

```python
# domain/stream_output.py
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
from uuid import uuid4

class StreamSource(Enum):
    PTY = "pty"
    PIPE = "pipe"

@dataclass(frozen=True)  # Inmutable como value object
class StreamOutput:
    content: str
    source: StreamSource
    timestamp: datetime
    id: str = None
    metadata: dict = None
    
    def __post_init__(self):
        if not self.id:
            object.__setattr__(self, 'id', str(uuid4()))
        if not self.metadata:
            object.__setattr__(self, 'metadata', {})
```

#### Comunicación Thread-Safe
```
Worker Thread (PTY reader)
   ├─ Lee output línea a línea
   ├─ Crea StreamOutput(content, source)
   └─ queue.put(stream_output)  ← thread-safe, no espera
        ↓
    queue.Queue (bounded, 1000 items)
   ├─ Buffers automáticamente
   ├─ No depende de timing TUI
   └─ El TUI consume cuando puede
        ↓
TUI Event Loop (async)
   ├─ set_interval() llama _consume_stream_queue()
   ├─ Vacía queue en chunks (ej: 10 items por tick)
   └─ Procesa cada StreamOutput
        ↓
    SERVICES LAYER (services/stream_service.py)
   ├─ Sanitiza contenido
   ├─ Bufferea para rendering
   └─ Retorna para live_feed.write()
        ↓
TUI LAYER (tui/widgets/live_feed.py)
   └─ Renderiza StreamOutput sanitizado
```

#### Implementación

**PORTS** (`ports/stream_queue_port.py`):
```python
from abc import ABC, abstractmethod
from queue import Queue
from laida_cli.domain.stream_output import StreamOutput

class StreamQueuePort(ABC):
    """Puerto para comunicación thread-safe entre worker y TUI"""
    
    @abstractmethod
    def put(self, stream_output: StreamOutput) -> None:
        """Escribe en queue (thread-safe)"""
        pass
    
    @abstractmethod
    def get_all(self) -> list[StreamOutput]:
        """Lee todos items pendientes (no bloqueante)"""
        pass
    
    @abstractmethod
    def is_empty(self) -> bool:
        pass
```

**ADAPTERS** (`adapters/stream_queue_adapter.py`):
```python
from queue import Queue, Empty
from laida_cli.domain.stream_output import StreamOutput
from laida_cli.ports.stream_queue_port import StreamQueuePort

class BoundedStreamQueueAdapter(StreamQueuePort):
    """Queue thread-safe con límite de tamaño"""
    
    def __init__(self, max_size: int = 1000):
        self.queue = Queue(maxsize=max_size)
        self.dropped_count = 0
    
    def put(self, stream_output: StreamOutput) -> None:
        """Escribe StreamOutput, descarta antiguo si overflow"""
        try:
            self.queue.put_nowait(stream_output)
        except:  # Full
            try:
                self.queue.get_nowait()  # Descartar más antiguo
                self.queue.put_nowait(stream_output)
                self.dropped_count += 1
            except:
                pass
    
    def get_all(self) -> list[StreamOutput]:
        """Vacía queue, retorna lista (no bloqueante)"""
        items = []
        while not self.queue.empty():
            try:
                items.append(self.queue.get_nowait())
            except Empty:
                break
        return items
    
    def is_empty(self) -> bool:
        return self.queue.empty()
    
    def stats(self) -> dict:
        return {
            "pending": self.queue.qsize(),
            "dropped": self.dropped_count
        }
```

**SERVICES** (`services/stream_service.py`):
```python
from laida_cli.ports.stream_queue_port import StreamQueuePort
from laida_cli.domain.stream_output import StreamOutput

class StreamService:
    """Orquesta la comunicación worker ↔ TUI mediante queue"""
    
    def __init__(self, queue: StreamQueuePort):
        self.queue = queue
    
    def write_stream(self, content: str, source):
        """Worker thread usa esto para escribir sin bloquear"""
        stream_output = StreamOutput(
            content=content,
            source=source,
            timestamp=datetime.now()
        )
        self.queue.put(stream_output)
    
    def consume_all(self) -> list[StreamOutput]:
        """TUI usa esto para consumir sin bloquear"""
        return self.queue.get_all()
```

**TUI INTEGRATION** (`tui/services/stream_integration_service.py`):
```python
from laida_cli.services.stream_service import StreamService
from laida_cli.ports.sanitizer_port import SanitizerPort

class StreamIntegrationService:
    """Integración TUI: consume queue + sanitiza"""
    
    def __init__(self, stream_service: StreamService, sanitizer: SanitizerPort):
        self.stream_service = stream_service
        self.sanitizer = sanitizer
    
    def get_sanitized_batch(self) -> str:
        """Retorna batch sanitizado listo para renderizar"""
        outputs = self.stream_service.consume_all()
        
        sanitized_lines = []
        for output in outputs:
            sanitized = self.sanitizer.sanitize(output.content)
            sanitized_lines.append(sanitized)
        
        return "\n".join(sanitized_lines)
```

---

### **BEST PRACTICE #2: Output Sanitization Filter (Antes de Mostrar)**

#### Problema
```
Rutas expuestas en output:
/home/alexalves87/projects/prueba/.venv/lib/...
/projects/secret-trading/...
pypi-AgEIcHlwaS5vcmcCJDVkZWIzOTlmLWNmOWUtNGM0NC1iNWQ0...
```

#### Solución Hexagonal

**DOMAIN** (`domain/sanitization_rule.py`):
```python
from dataclasses import dataclass
from typing import Callable
import re

@dataclass(frozen=True)
class SanitizationRule:
    """Regla de sanitización (inmutable)"""
    name: str
    pattern: str  # regex
    replacement: str
    priority: int = 0  # Orden de aplicación
    
    def apply(self, text: str) -> str:
        return re.sub(self.pattern, self.replacement, text)
```

**PORTS** (`ports/sanitizer_port.py`):
```python
from abc import ABC, abstractmethod
from typing import list
from laida_cli.domain.sanitization_rule import SanitizationRule

class SanitizerPort(ABC):
    """Puerto para sanitización de output"""
    
    @abstractmethod
    def sanitize(self, text: str) -> str:
        """Aplica todas las reglas de sanitización"""
        pass
    
    @abstractmethod
    def add_rule(self, rule: SanitizationRule) -> None:
        pass
    
    @abstractmethod
    def get_rules(self) -> list[SanitizationRule]:
        pass
```

**ADAPTERS** (`adapters/regex_sanitizer_adapter.py`):
```python
from laida_cli.ports.sanitizer_port import SanitizerPort
from laida_cli.domain.sanitization_rule import SanitizationRule
from pathlib import Path
import re

class RegexSanitizerAdapter(SanitizerPort):
    """Sanitización basada en regex (configurable)"""
    
    def __init__(self, user_home: str = None):
        self.rules: list[SanitizationRule] = []
        self._init_default_rules(user_home)
    
    def _init_default_rules(self, user_home: str):
        """Inicializa reglas por defecto"""
        
        rules = [
            SanitizationRule(
                name="user_home",
                pattern=re.escape(user_home or str(Path.home())),
                replacement="[USER_HOME]",
                priority=10  # Aplicar primero
            ),
            SanitizationRule(
                name="venv_paths",
                pattern=r"\.venv/lib/python\d+\.\d+/site-packages",
                replacement="[VENV]",
                priority=9
            ),
            SanitizationRule(
                name="project_secret",
                pattern=r"/projects/[a-zA-Z0-9_-]*(?:secret|private|key)[a-zA-Z0-9_-]*",
                replacement="[PROJECT_SECRET]",
                priority=8
            ),
            SanitizationRule(
                name="api_token_pypi",
                pattern=r"(pypi-)[a-zA-Z0-9_-]{40,}",
                replacement="[REDACTED_PYPI_TOKEN]",
                priority=7
            ),
            SanitizationRule(
                name="api_token_sk",
                pattern=r"(sk-)[a-zA-Z0-9_-]{40,}",
                replacement="[REDACTED_SK_TOKEN]",
                priority=6
            ),
            SanitizationRule(
                name="api_token_generic",
                pattern=r"([a-z0-9]{40,})",  # Tokens genéricos largos
                replacement="[REDACTED_TOKEN]",
                priority=5
            ),
        ]
        
        for rule in sorted(rules, key=lambda r: -r.priority):
            self.rules.append(rule)
    
    def sanitize(self, text: str) -> str:
        """Aplica todas reglas en orden de prioridad"""
        result = text
        for rule in self.rules:
            result = rule.apply(result)
        return result
    
    def add_rule(self, rule: SanitizationRule) -> None:
        self.rules.append(rule)
        self.rules.sort(key=lambda r: -r.priority)
    
    def get_rules(self) -> list[SanitizationRule]:
        return self.rules.copy()
```

**SERVICES** (`services/sanitization_service.py`):
```python
from laida_cli.ports.sanitizer_port import SanitizerPort
from laida_cli.domain.stream_output import StreamOutput

class SanitizationService:
    """Servicio de sanitización (caso de uso)"""
    
    def __init__(self, sanitizer: SanitizerPort):
        self.sanitizer = sanitizer
    
    def sanitize_stream_output(self, output: StreamOutput) -> StreamOutput:
        """Sanitiza y retorna nuevo StreamOutput"""
        sanitized_content = self.sanitizer.sanitize(output.content)
        
        return StreamOutput(
            content=sanitized_content,
            source=output.source,
            timestamp=output.timestamp,
            id=output.id,
            metadata={
                **output.metadata,
                "was_sanitized": True,
                "rules_applied": len(self.sanitizer.get_rules())
            }
        )
```

---

### **BEST PRACTICE #3: PTY vs Subprocess Hybrid (Elegir Correctamente)**

#### Problema
```
Algunos comandos se comportan diferente en PTY vs PIPE
Claude CLI podría no escribir correctamente a PTY
```

#### Solución Hexagonal

**DOMAIN** (`domain/stream_source.py`):
```python
from enum import Enum

class StreamSourceType(Enum):
    """Tipo de fuente de stream"""
    PTY = "pty"          # Terminal interactiva
    PIPE = "pipe"        # Pipe no-interactivo
    AUTO = "auto"        # Detectar automáticamente

class CommandType(Enum):
    """Tipo de comando a ejecutar"""
    INTERACTIVE = "interactive"    # login, auth, input
    NON_INTERACTIVE = "non_interactive"  # build, test, code
    AUTO = "auto"
```

**PORTS** (`ports/stream_reader_port.py`):
```python
from abc import ABC, abstractmethod
from laida_cli.domain.stream_output import StreamOutput
from typing import Callable

class StreamReaderPort(ABC):
    """Puerto para lectura de stream desde subprocess"""
    
    @abstractmethod
    def start(self, callback: Callable[[StreamOutput], None]) -> None:
        """Inicia lectura en thread dedicado"""
        pass
    
    @abstractmethod
    def stop(self) -> None:
        """Detiene lectura gracefully"""
        pass
    
    @abstractmethod
    def is_running(self) -> bool:
        pass
    
    @abstractmethod
    def get_stats(self) -> dict:
        """Retorna estadísticas: lines_read, errors, etc"""
        pass
```

**ADAPTERS** (`adapters/stream_reader_adapter.py`):
```python
import subprocess
import pty
import os
import fcntl
import select
import threading
import time
from laida_cli.ports.stream_reader_port import StreamReaderPort
from laida_cli.domain.stream_output import StreamOutput, StreamSource
from laida_cli.domain.stream_source import StreamSourceType

class HybridStreamReaderAdapter(StreamReaderPort):
    """Lee stream usando PTY o PIPE según tipo de comando"""
    
    def __init__(
        self,
        command: list,
        source_type: StreamSourceType = StreamSourceType.AUTO,
        timeout: int = 180
    ):
        self.command = command
        self.source_type = source_type
        self.timeout = timeout
        self.process = None
        self._thread = None
        self._running = False
        self._stats = {
            "lines_read": 0,
            "errors": 0,
            "source_used": None
        }
    
    def _detect_source_type(self) -> StreamSourceType:
        """Detecta si comando es interactivo"""
        interactive_keywords = ["login", "auth", "password", "input"]
        cmd_str = " ".join(self.command).lower()
        
        for keyword in interactive_keywords:
            if keyword in cmd_str:
                return StreamSourceType.PTY
        
        return StreamSourceType.PIPE
    
    def start(self, callback) -> None:
        """Inicia lectura en thread dedicado"""
        source_type = (
            self._detect_source_type()
            if self.source_type == StreamSourceType.AUTO
            else self.source_type
        )
        
        self._running = True
        self._thread = threading.Thread(
            target=self._read_stream,
            args=(callback, source_type),
            daemon=True,
            name=f"StreamReader-{source_type.value}"
        )
        self._thread.start()
    
    def _read_stream(self, callback, source_type: StreamSourceType) -> None:
        """Lee stream (PTY o PIPE) y llama callback"""
        try:
            if source_type == StreamSourceType.PTY:
                self._read_pty(callback)
            else:
                self._read_pipe(callback)
        except Exception as e:
            self._stats["errors"] += 1
            callback(StreamOutput(
                content=f"[#f87171]Error reading stream: {e}[/]",
                source=source_type
            ))
        finally:
            self._running = False
    
    def _read_pty(self, callback) -> None:
        """Lee desde PTY (interactivo)"""
        master_fd, slave_fd = pty.openpty()
        
        self.process = subprocess.Popen(
            self.command,
            stdin=slave_fd,
            stdout=slave_fd,
            stderr=slave_fd,
            preexec_fn=os.setsid,
            text=True
        )
        
        os.close(slave_fd)
        os.fcntl(master_fd, os.fcntl.F_SETFL, os.O_NONBLOCK)
        
        buffer = ""
        start_time = time.time()
        
        while self._running and (time.time() - start_time) < self.timeout:
            ready, _, _ = select.select([master_fd], [], [], 0.1)
            
            if ready:
                try:
                    data = os.read(master_fd, 4096)
                    if data:
                        buffer += data.decode('utf-8', errors='replace')
                        
                        # Procesar líneas completas
                        while "\n" in buffer:
                            line, buffer = buffer.split("\n", 1)
                            self._stats["lines_read"] += 1
                            callback(StreamOutput(
                                content=line,
                                source=StreamSource.PTY
                            ))
                except:
                    pass
            
            if self.process and self.process.poll() is not None:
                break
        
        # Procesar buffer restante
        if buffer.strip():
            callback(StreamOutput(
                content=buffer,
                source=StreamSource.PTY
            ))
        
        self._stats["source_used"] = "PTY"
        os.close(master_fd)
    
    def _read_pipe(self, callback) -> None:
        """Lee desde PIPE (no-interactivo)"""
        self.process = subprocess.Popen(
            self.command,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1
        )
        
        start_time = time.time()
        
        for line in self.process.stdout:
            if not self._running or (time.time() - start_time) > self.timeout:
                break
            
            self._stats["lines_read"] += 1
            callback(StreamOutput(
                content=line.rstrip("\n"),
                source=StreamSource.PIPE
            ))
        
        self.process.wait(timeout=5)
        self._stats["source_used"] = "PIPE"
    
    def stop(self) -> None:
        """Detiene gracefully"""
        self._running = False
        if self.process:
            self.process.terminate()
            try:
                self.process.wait(timeout=5)
            except:
                self.process.kill()
    
    def is_running(self) -> bool:
        return self._running
    
    def get_stats(self) -> dict:
        return self._stats.copy()
```

---

### **BEST PRACTICE #4: Async/Sync Boundary Management**

#### Problema
```
Mezcla de async/sync causa RuntimeError
Worker thread ≠ TUI thread
```

#### Arquitectura Clara

**DOMAIN** (`domain/execution_context.py`):
```python
from enum import Enum
from dataclasses import dataclass

class ExecutionContext(Enum):
    TUI_ASYNC = "tui_async"        # Main event loop
    WORKER_SYNC = "worker_sync"    # Thread dedicado
    SERVICE_SYNC = "service_sync"  # Services layer
```

**Patrón de Ejecución**:

```
┌─ TUI THREAD (ASYNC) ──────────────┐
│ class TUIApp(App):                │
│   async def action_login(self):   │
│     ├─ Crear services             │
│     ├─ Lanzar worker thread       │
│     └─ set_interval() para        │
│        consumir resultados        │
└───────────────────────────────────┘
         ↓ (threading.Thread)
┌─ WORKER THREAD (SYNC) ────────────┐
│ def _stream_claude_login():       │
│   ├─ reader.start(callback)       │
│   └─ callback usa queue.put()     │
│       (NO call_from_thread)       │
└───────────────────────────────────┘
         ↓ (Queue)
┌─ QUEUE (THREAD-SAFE) ─────────────┐
│ StreamOutput objects              │
│ Sin timing dependencies           │
└───────────────────────────────────┘
         ↓ (TUI consumes)
┌─ TUI THREAD (ASYNC) ──────────────┐
│ async def _consume_stream_queue(): │
│   ├─ integration_service.         │
│   │  get_sanitized_batch()        │
│   └─ self._live_feed.write()      │
└───────────────────────────────────┘
```

**SERVICES** (`services/command_execution_service.py`):
```python
from laida_cli.ports.stream_reader_port import StreamReaderPort
from laida_cli.ports.stream_queue_port import StreamQueuePort
from laida_cli.ports.sanitizer_port import SanitizerPort
import threading

class CommandExecutionService:
    """Orquesta ejecución de comandos con límites claros"""
    
    def __init__(
        self,
        stream_reader: StreamReaderPort,
        queue: StreamQueuePort,
        sanitizer: SanitizerPort
    ):
        self.stream_reader = stream_reader
        self.queue = queue
        self.sanitizer = sanitizer
    
    def execute_command(self, command: list) -> None:
        """
        SYNC: Ejecuta comando en thread separado
        NO debe ser llamado desde TUI event loop
        """
        thread = threading.Thread(
            target=self._worker_execute,
            args=(command,),
            daemon=True
        )
        thread.start()
    
    def _worker_execute(self, command: list) -> None:
        """SYNC: Corre en worker thread"""
        self.stream_reader.start(
            callback=self._on_stream_output
        )
    
    def _on_stream_output(self, output) -> None:
        """SYNC: Callback desde reader (mismo thread)"""
        sanitized_output = self.sanitizer.sanitize(output.content)
        
        # Poner en queue (thread-safe)
        from laida_cli.domain.stream_output import StreamOutput
        queue_item = StreamOutput(
            content=sanitized_output,
            source=output.source,
            timestamp=output.timestamp,
            metadata={"sanitized": True}
        )
        self.queue.put(queue_item)
```

**TUI INTEGRATION** (`tui/app.py`):
```python
class TUIApp(App):
    async def action_login(self) -> None:
        """ASYNC: Acción del TUI"""
        # Inicializar services
        self.command_execution_service.execute_command(["claude", "login"])
        
        # Consumir resultados periódicamente (NO bloqueante)
        self.set_interval(0.1, self._consume_and_render)
    
    async def _consume_and_render(self) -> None:
        """ASYNC: Consume queue desde TUI sin bloquear"""
        batch = self.integration_service.get_sanitized_batch()
        
        if batch:
            # Esto es thread-safe porque estamos en event loop
            self._live_feed.write(batch)
```

---

### **BEST PRACTICE #5: Buffering & Rendering Strategy**

#### Problema
```
Output línea por línea → muchos redraws de Textual
Performance pobre + parpadeos visuales
```

#### Solución Hexagonal

**DOMAIN** (`domain/render_buffer.py`):
```python
from dataclasses import dataclass, field
from datetime import datetime
from typing import list

@dataclass
class RenderBuffer:
    """Buffer para acumular antes de renderizar"""
    max_lines: int = 10  # Chunks de N líneas
    lines: list = field(default_factory=list)
    created_at: datetime = field(default_factory=datetime.now)
    
    def add_line(self, line: str) -> None:
        self.lines.append(line)
    
    def should_flush(self) -> bool:
        """Flush si buffer está lleno"""
        return len(self.lines) >= self.max_lines
    
    def flush(self) -> str:
        """Retorna contenido y limpia"""
        content = "\n".join(self.lines)
        self.lines.clear()
        self.created_at = datetime.now()
        return content
    
    def is_empty(self) -> bool:
        return len(self.lines) == 0
```

**ADAPTERS** (`adapters/buffered_renderer_adapter.py`):
```python
from laida_cli.domain.render_buffer import RenderBuffer
from laida_cli.domain.stream_output import StreamOutput
from typing import Callable

class BufferedRendererAdapter:
    """Renderiza en chunks para mejor performance"""
    
    def __init__(self, buffer_size: int = 10):
        self.buffer = RenderBuffer(max_lines=buffer_size)
    
    def process_output(
        self,
        outputs: list[StreamOutput],
        on_render: Callable[[str], None]
    ) -> None:
        """
        Procesa lista de outputs:
        - Acumula en buffer
        - Renderiza cuando alcanza tamaño
        """
        for output in outputs:
            self.buffer.add_line(output.content)
            
            if self.buffer.should_flush():
                content = self.buffer.flush()
                on_render(content)
        
        # Flush final si hay contenido pendiente
        if not self.buffer.is_empty():
            content = self.buffer.flush()
            on_render(content)
```

**SERVICES** (`services/render_service.py`):
```python
from laida_cli.adapters.buffered_renderer_adapter import BufferedRendererAdapter
from laida_cli.ports.stream_queue_port import StreamQueuePort

class RenderService:
    """Servicio de renderización con buffering"""
    
    def __init__(self, queue: StreamQueuePort, buffer_size: int = 10):
        self.queue = queue
        self.renderer = BufferedRendererAdapter(buffer_size=buffer_size)
    
    def render_pending(self, on_render) -> None:
        """
        Obtiene pending del queue y renderiza en chunks
        """
        pending = self.queue.get_all()
        self.renderer.process_output(pending, on_render)
```

**TUI INTEGRATION**:
```python
class TUIApp(App):
    async def _consume_and_render(self) -> None:
        """Consume + renderiza en chunks"""
        self.render_service.render_pending(
            on_render=self._live_feed.write
        )
```

---

### **BEST PRACTICE #6: Command Mapping Architecture (Centralizado)**

#### Problema
```
Comandos esparcidos (login, code, clear)
Difícil de mantener y agregar nuevos
```

#### Solución Hexagonal

**DOMAIN** (`domain/cli_command.py`):
```python
from dataclasses import dataclass
from enum import Enum
from typing import Optional, Callable

class CommandExecutionMode(Enum):
    INTERACTIVE = "interactive"
    NON_INTERACTIVE = "non_interactive"
    INTERNAL = "internal"

@dataclass(frozen=True)
class CLICommand:
    """Definición de un comando mapeado"""
    name: str
    executable: Optional[str]  # None si es interno
    args: list = None
    mode: CommandExecutionMode = CommandExecutionMode.NON_INTERACTIVE
    timeout: int = 300
    description: str = ""
    internal_handler: Optional[Callable] = None  # Si es internal
    
    def __post_init__(self):
        if self.args is None:
            object.__setattr__(self, 'args', [])
```

**PORTS** (`ports/command_registry_port.py`):
```python
from abc import ABC, abstractmethod
from typing import list, Optional
from laida_cli.domain.cli_command import CLICommand

class CommandRegistryPort(ABC):
    """Puerto para registro de comandos"""
    
    @abstractmethod
    def register(self, command: CLICommand) -> None:
        pass
    
    @abstractmethod
    def get(self, name: str) -> Optional[CLICommand]:
        pass
    
    @abstractmethod
    def list_all(self) -> list[CLICommand]:
        pass
```

**ADAPTERS** (`adapters/command_registry_adapter.py`):
```python
from laida_cli.ports.command_registry_port import CommandRegistryPort
from laida_cli.domain.cli_command import CLICommand, CommandExecutionMode
from typing import Optional, list, dict

class CommandRegistryAdapter(CommandRegistryPort):
    """Registro centralizado de comandos"""
    
    def __init__(self):
        self.commands: dict[str, CLICommand] = {}
        self._init_default_commands()
    
    def _init_default_commands(self) -> None:
        """Registra comandos por defecto"""
        
        commands = [
            CLICommand(
                name="login",
                executable="claude",
                args=[],
                mode=CommandExecutionMode.INTERACTIVE,
                timeout=180,
                description="Authenticate to Claude Code"
            ),
            CLICommand(
                name="code",
                executable="claude",
                args=["--code"],
                mode=CommandExecutionMode.NON_INTERACTIVE,
                timeout=300,
                description="Execute code with Claude"
            ),
            CLICommand(
                name="validate",
                executable=None,
                mode=CommandExecutionMode.INTERNAL,
                internal_handler=self._internal_validate,
                description="Validate LAIDA artifacts"
            ),
            CLICommand(
                name="clear",
                executable=None,
                mode=CommandExecutionMode.INTERNAL,
                internal_handler=self._internal_clear,
                description="Clear internal state"
            ),
        ]
        
        for cmd in commands:
            self.register(cmd)
    
    def register(self, command: CLICommand) -> None:
        self.commands[command.name] = command
    
    def get(self, name: str) -> Optional[CLICommand]:
        return self.commands.get(name)
    
    def list_all(self) -> list[CLICommand]:
        return list(self.commands.values())
    
    def _internal_validate(self) -> None:
        """Handler interno para validate"""
        pass
    
    def _internal_clear(self) -> None:
        """Handler interno para clear"""
        pass
```

**SERVICES** (`services/command_executor_service.py`):
```python
from laida_cli.ports.command_registry_port import CommandRegistryPort
from laida_cli.ports.stream_reader_port import StreamReaderPort
from laida_cli.domain.cli_command import CommandExecutionMode

class CommandExecutorService:
    """Ejecuta comandos registrados"""
    
    def __init__(
        self,
        registry: CommandRegistryPort,
        stream_reader: StreamReaderPort
    ):
        self.registry = registry
        self.stream_reader = stream_reader
    
    def execute(self, command_name: str, *args) -> bool:
        """Ejecuta comando por nombre"""
        command = self.registry.get(command_name)
        if not command:
            return False
        
        if command.mode == CommandExecutionMode.INTERNAL:
            # Ejecutar handler interno
            if command.internal_handler:
                command.internal_handler()
            return True
        
        elif command.mode in [
            CommandExecutionMode.INTERACTIVE,
            CommandExecutionMode.NON_INTERACTIVE
        ]:
            # Ejecutar comando externo
            full_cmd = [command.executable] + command.args + list(args)
            self.stream_reader.start(callback=lambda x: None)
            return True
        
        return False
    
    def list_commands(self) -> list:
        """Retorna comandos para help"""
        return [
            {
                "name": cmd.name,
                "description": cmd.description,
                "mode": cmd.mode.value
            }
            for cmd in self.registry.list_all()
        ]
```

**TUI INTEGRATION** (`tui/app.py`):
```python
from laida_cli.services.command_executor_service import CommandExecutorService

class TUIApp(App):
    def __init__(self):
        super().__init__()
        self.command_executor = CommandExecutorService(
            registry=CommandRegistryAdapter(),
            stream_reader=HybridStreamReaderAdapter([""])
        )
    
    def action_login(self) -> None:
        """Ejecuta login via command executor"""
        self.command_executor.execute("login")
    
    def action_validate(self) -> None:
        """Ejecuta validate via command executor"""
        self.command_executor.execute("validate")
    
    def show_help(self) -> None:
        """Muestra comandos disponibles"""
        commands = self.command_executor.list_commands()
        # Mostrar en panel...
```

---

## 📐 ESTRUCTURA DE DIRECTORIOS FINAL

```
src/laida_cli/
├── domain/
│   ├── stream_output.py              (StreamOutput, StreamSource)
│   ├── stream_source.py              (StreamSourceType, CommandType)
│   ├── sanitization_rule.py          (SanitizationRule)
│   ├── render_buffer.py              (RenderBuffer)
│   ├── execution_context.py          (ExecutionContext)
│   ├── cli_command.py                (CLICommand)
│   └── ... (existentes)
│
├── ports/
│   ├── stream_queue_port.py          (NEW)
│   ├── stream_reader_port.py         (NEW)
│   ├── sanitizer_port.py             (NEW)
│   ├── command_registry_port.py      (NEW)
│   └── ... (existentes)
│
├── adapters/
│   ├── stream_queue_adapter.py       (NEW)
│   ├── stream_reader_adapter.py      (NEW)
│   ├── regex_sanitizer_adapter.py    (NEW)
│   ├── command_registry_adapter.py   (NEW)
│   ├── buffered_renderer_adapter.py  (NEW)
│   └── ... (existentes)
│
├── services/
│   ├── stream_service.py             (NEW)
│   ├── sanitization_service.py       (NEW)
│   ├── command_execution_service.py  (NEW)
│   ├── render_service.py             (NEW)
│   ├── command_executor_service.py   (NEW)
│   └── ... (existentes)
│
├── tui/
│   ├── services/
│   │   └── stream_integration_service.py  (NEW)
│   ├── widgets/
│   │   └── live_feed.py              (ACTUALIZAR)
│   └── app.py                        (ACTUALIZAR)
│
└── commands/
    ├── login.py                      (ACTUALIZAR: usar command_executor)
    ├── run.py                        (ACTUALIZAR)
    └── ... (existentes)
```

---

## ✅ CHECKLIST DE IMPLEMENTACIÓN

### Fase 1: Domain + Ports
```
[ ] domain/stream_output.py
[ ] domain/stream_source.py
[ ] domain/sanitization_rule.py
[ ] domain/render_buffer.py
[ ] domain/cli_command.py
[ ] ports/stream_queue_port.py
[ ] ports/stream_reader_port.py
[ ] ports/sanitizer_port.py
[ ] ports/command_registry_port.py
```

### Fase 2: Adapters
```
[ ] adapters/stream_queue_adapter.py
[ ] adapters/stream_reader_adapter.py
[ ] adapters/regex_sanitizer_adapter.py
[ ] adapters/command_registry_adapter.py
[ ] adapters/buffered_renderer_adapter.py
```

### Fase 3: Services
```
[ ] services/stream_service.py
[ ] services/sanitization_service.py
[ ] services/command_execution_service.py
[ ] services/render_service.py
[ ] services/command_executor_service.py
```

### Fase 4: TUI Integration
```
[ ] tui/services/stream_integration_service.py
[ ] tui/app.py (action_login, _consume_stream_queue)
[ ] tui/widgets/live_feed.py (actualizar)
```

### Fase 5: Testing
```
[ ] tests/unit/domain/test_stream_output.py
[ ] tests/unit/adapters/test_stream_queue_adapter.py
[ ] tests/unit/adapters/test_sanitizer_adapter.py
[ ] tests/unit/services/test_stream_service.py
[ ] tests/integration/test_tui_streaming.py
```

---

## 🎯 TIMELINE ESTIMADO

**Semana 1: Domain + Ports + Core Adapters**
- 3 días: Implementar domain + ports
- 2 días: Implementar adapters core (queue, reader, sanitizer)

**Semana 2: Services + Integration**
- 2 días: Servicios (stream, sanitization, execution)
- 2 días: Integración TUI
- 1 día: Testing básico

**Semana 3: Command Mapping + Polish**
- 2 días: Command registry + executor
- 1 día: Integración con CLI commands existentes
- 2 días: Testing e2e + documentación

---

## 📝 NOTAS FINALES

✅ **Respeta arquitectura hexagonal completamente**  
✅ **Desacopla TUI de worker via Queue**  
✅ **Sanitización centralizada antes de renderizar**  
✅ **PTY/PIPE selección automática**  
✅ **Límites claros async/sync**  
✅ **Buffering para mejor performance**  
✅ **Comandos mapeados y centralizados**  

**Preparado para**: Javi (LAIDA CLI Wrapper)  
**Contexto**: Claude Code Output Integration  
**Fecha**: 2025-12-23  
**Status**: Ready for Implementation