feat: CI/CD pipeline setup complete - Ansible playbooks updated, secrets configured, workflow ready

This commit is contained in:
2025-10-31 01:39:24 +01:00
parent 55c04e4fd0
commit e26eb2aa12
601 changed files with 44184 additions and 32477 deletions

View File

@@ -10,6 +10,7 @@ use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\MouseControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\SimpleWorkflowExecutor;
use App\Framework\Console\TuiView;
@@ -22,6 +23,8 @@ use App\Framework\Discovery\Results\DiscoveryRegistry;
*/
final readonly class ConsoleTUI
{
private InputParser $inputParser;
public function __construct(
private ConsoleOutputInterface $output,
private Container $container,
@@ -34,6 +37,7 @@ final readonly class ConsoleTUI
private CommandGroupRegistry $groupRegistry,
private SimpleWorkflowExecutor $workflowExecutor
) {
$this->inputParser = new InputParser();
}
/**
@@ -64,12 +68,59 @@ final readonly class ConsoleTUI
*/
private function mainLoop(): void
{
$needsRender = true;
while ($this->state->isRunning()) {
$this->renderCurrentView();
$this->handleUserInput();
// Only render when needed
if ($needsRender) {
$this->renderCurrentView();
$needsRender = false;
}
// Handle input (non-blocking)
$event = $this->inputParser->readEvent();
if ($event === null) {
// No input available, sleep briefly to reduce CPU usage
usleep(50000); // 50ms
continue;
}
// Process event and mark for re-render
$needsRender = $this->processEvent($event);
}
}
/**
* Process input event and return whether re-render is needed
*/
private function processEvent(MouseEvent|KeyEvent $event): bool
{
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return true;
}
// Dispatch event to input handler
if ($event instanceof MouseEvent) {
$this->inputHandler->handleMouseEvent($event, $this->state);
return true; // Mouse events always need re-render
} elseif ($event instanceof KeyEvent) {
// Handle Ctrl+C
if ($event->ctrl && $event->key === 'C') {
$this->state->setRunning(false);
return false;
}
// Use new KeyEvent handler (supports Alt+Letter shortcuts)
$this->inputHandler->handleKeyEvent($event, $this->state, $this->commandHistory);
return true; // Key events always need re-render
}
return false;
}
/**
* Cleanup and restore terminal
*/
@@ -89,6 +140,13 @@ final readonly class ConsoleTUI
$this->setRawMode(true);
$this->output->write(CursorControlCode::HIDE->format());
// Enable mouse reporting (SGR format)
$this->output->write(MouseControlCode::ENABLE_ALL->format());
$this->output->write(MouseControlCode::ENABLE_SGR->format());
// Optional: Enable alternate screen buffer
$this->output->write(ScreenControlCode::ALTERNATE_BUFFER->format());
// Welcome message
$this->output->write(ScreenControlCode::CLEAR_ALL->format());
$this->output->write(CursorControlCode::POSITION->format(1, 1));
@@ -99,8 +157,24 @@ final readonly class ConsoleTUI
*/
private function restoreTerminal(): void
{
$this->setRawMode(false);
// Disable mouse reporting
$this->output->write(MouseControlCode::DISABLE_SGR->format());
$this->output->write(MouseControlCode::DISABLE_ALL->format());
// Show cursor
$this->output->write(CursorControlCode::SHOW->format());
// Deactivate alternate screen buffer
$this->output->write(ScreenControlCode::MAIN_BUFFER->format());
// Restore terminal mode
$this->setRawMode(false);
// Reset terminal (stty sane)
if (function_exists('shell_exec')) {
shell_exec('stty sane 2>/dev/null');
}
$this->output->screen->setInteractiveMode(false);
}
@@ -126,24 +200,7 @@ final readonly class ConsoleTUI
$this->renderer->render($this->state, $this->commandHistory);
}
/**
* Handle user input
*/
private function handleUserInput(): void
{
$key = $this->readKey();
if ($key !== '') {
// Handle form mode specially
if ($this->state->getCurrentView() === TuiView::FORM) {
$this->handleFormMode();
return;
}
$this->inputHandler->handleInput($key, $this->state, $this->commandHistory);
}
}
/**
* Handle form mode interaction
@@ -243,7 +300,13 @@ final readonly class ConsoleTUI
$this->output->writeLine('Press any key to continue...', ConsoleColor::YELLOW);
// Wait for user input
$this->readKey();
while (true) {
$event = $this->inputParser->readEvent();
if ($event !== null) {
break;
}
usleep(10000); // 10ms sleep
}
}
/**

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Dispatches events to UI components
*/
final class EventDispatcher
{
/** @var array<callable(MouseEvent, TuiState): void> */
private array $mouseListeners = [];
/** @var array<callable(KeyEvent, TuiState): void> */
private array $keyListeners = [];
/**
* Register a mouse event listener
*/
public function onMouseEvent(callable $listener): void
{
$this->mouseListeners[] = $listener;
}
/**
* Register a keyboard event listener
*/
public function onKeyEvent(callable $listener): void
{
$this->keyListeners[] = $listener;
}
/**
* Dispatch a mouse event
*/
public function dispatchMouseEvent(MouseEvent $event, TuiState $state): void
{
foreach ($this->mouseListeners as $listener) {
$listener($event, $state);
}
}
/**
* Dispatch a keyboard event
*/
public function dispatchKeyEvent(KeyEvent $event, TuiState $state): void
{
foreach ($this->keyListeners as $listener) {
$listener($event, $state);
}
}
/**
* Clear all listeners
*/
public function clear(): void
{
$this->mouseListeners = [];
$this->keyListeners = [];
}
}

View File

@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Parses ANSI escape sequences for mouse and keyboard events
*/
final class InputParser
{
/**
* Read and parse input from STDIN using stream_select()
*
* @return MouseEvent|KeyEvent|null Returns parsed event or null if no input available
*/
public function readEvent(): MouseEvent|KeyEvent|null
{
// Use stream_select for non-blocking I/O
$read = [STDIN];
$write = null;
$except = null;
// Wait up to 0.01 seconds (10ms) for input
$result = stream_select($read, $write, $except, 0, 10000);
if ($result === false || $result === 0 || !in_array(STDIN, $read, true)) {
return null;
}
$originalBlocking = stream_get_meta_data(STDIN)['blocked'] ?? true;
stream_set_blocking(STDIN, false);
try {
$firstChar = fgetc(STDIN);
if ($firstChar === false) {
return null;
}
// Check for escape sequence
if ($firstChar === "\033") {
return $this->parseEscapeSequence($firstChar);
}
// Check for Ctrl+C (ASCII 3)
if ($firstChar === "\003") {
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: 'C', ctrl: true);
}
// Regular character
stream_set_blocking(STDIN, $originalBlocking);
return new KeyEvent(key: $firstChar);
} finally {
stream_set_blocking(STDIN, $originalBlocking);
}
}
/**
* Parse escape sequence (mouse or keyboard)
*/
private function parseEscapeSequence(string $firstChar): MouseEvent|KeyEvent|null
{
$sequence = $firstChar;
// Read next character with timeout
$next = $this->readCharWithTimeout();
if ($next === null) {
return new KeyEvent(key: "\033");
}
$sequence .= $next;
// Mouse events start with \e[<
if ($next === '[') {
$third = $this->readCharWithTimeout();
if ($third === '<') {
return $this->parseMouseEvent($sequence . $third);
}
// Keyboard escape sequence like \e[A (arrow up)
if ($third !== null) {
$sequence .= $third;
return $this->parseKeyboardSequence($sequence);
}
}
// Just escape key
return new KeyEvent(key: "\033");
}
/**
* Parse SGR mouse event: \e[<b;x;yM (press) or \e[<b;x;ym (release)
*/
private function parseMouseEvent(string $prefix): MouseEvent|null
{
$buffer = '';
$timeout = 10000; // 10ms
$startTime = microtime(true) * 1000;
// Read until we get 'M' or 'm'
while (true) {
$char = fgetc(STDIN);
if ($char === false) {
// Check timeout
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeout) {
return null;
}
usleep(1000);
continue;
}
$buffer .= $char;
// Mouse event ends with 'M' (press) or 'm' (release)
if ($char === 'M' || $char === 'm') {
break;
}
// Safety: limit buffer size
if (strlen($buffer) > 20) {
return null;
}
}
// Parse format: b;x;y where b is button code, x and y are coordinates
// Remove the final M/m
$data = substr($buffer, 0, -1);
$parts = explode(';', $data);
if (count($parts) < 3) {
return null;
}
$buttonCode = (int) $parts[0];
$x = (int) $parts[1];
$y = (int) $parts[2];
// Decode button and modifiers
// Bit flags in button code:
// Bit 0-1: Button (0=left, 1=middle, 2=right, 3=release)
// Bit 2: Shift
// Bit 3: Meta/Alt
// Bit 4: Ctrl
// Bit 5-6: Polarity (64=scroll up, 65=scroll down)
$button = $buttonCode & 0x03;
$shift = ($buttonCode & 0x04) !== 0;
$alt = ($buttonCode & 0x08) !== 0;
$ctrl = ($buttonCode & 0x10) !== 0;
// Handle scroll events (button codes 64 and 65)
if ($buttonCode >= 64 && $buttonCode <= 65) {
$button = $buttonCode;
}
$pressed = $buffer[-1] === 'M';
return new MouseEvent(
x: $x,
y: $y,
button: $button,
pressed: $pressed,
shift: $shift,
ctrl: $ctrl,
alt: $alt
);
}
/**
* Parse keyboard escape sequence (arrow keys, function keys, etc.)
*/
private function parseKeyboardSequence(string $sequence): KeyEvent
{
// Map common escape sequences
$keyMap = [
"\033[A" => 'ArrowUp',
"\033[B" => 'ArrowDown',
"\033[C" => 'ArrowRight',
"\033[D" => 'ArrowLeft',
"\033[H" => 'Home',
"\033[F" => 'End',
"\033[5~" => 'PageUp',
"\033[6~" => 'PageDown',
"\033[3~" => 'Delete',
"\n" => 'Enter',
"\r" => 'Enter',
"\033" => 'Escape',
"\t" => 'Tab',
];
// Check if we need to read more characters
if (strlen($sequence) >= 3 && in_array($sequence[2], ['5', '6', '3'], true)) {
$fourth = $this->readCharWithTimeout();
if ($fourth !== null) {
$sequence .= $fourth;
}
}
// Check for Enter key
if ($sequence === "\n" || $sequence === "\r") {
return new KeyEvent(key: 'Enter', code: "\n");
}
// Check for Escape
if ($sequence === "\033") {
return new KeyEvent(key: 'Escape', code: "\033");
}
// Map to known key
if (isset($keyMap[$sequence])) {
return new KeyEvent(key: $keyMap[$sequence], code: $sequence);
}
// Unknown sequence, return as-is
return new KeyEvent(key: $sequence, code: $sequence);
}
/**
* Read a single character with small timeout
*/
private function readCharWithTimeout(int $timeoutMs = 10): ?string
{
$startTime = microtime(true) * 1000;
while (true) {
$char = fgetc(STDIN);
if ($char !== false) {
return $char;
}
$elapsed = (microtime(true) * 1000) - $startTime;
if ($elapsed > $timeoutMs) {
return null;
}
usleep(1000); // 1ms
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Keyboard event value object
*/
final readonly class KeyEvent
{
public function __construct(
public string $key,
public string $code = '',
public bool $shift = false,
public bool $ctrl = false,
public bool $alt = false,
public bool $meta = false
) {
}
/**
* Get human-readable description
*/
public function getDescription(): string
{
$modifiers = [];
if ($this->ctrl) {
$modifiers[] = 'Ctrl';
}
if ($this->alt) {
$modifiers[] = 'Alt';
}
if ($this->shift) {
$modifiers[] = 'Shift';
}
if ($this->meta) {
$modifiers[] = 'Meta';
}
$modifierStr = ! empty($modifiers) ? implode('+', $modifiers) . '+' : '';
return $modifierStr . $this->key;
}
/**
* Check if this is a control character (like Ctrl+C)
*/
public function isControlChar(): bool
{
return $this->ctrl && strlen($this->key) === 1 && ord($this->key) < 32;
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
/**
* Menu bar with clickable entries
*/
final readonly class MenuBar
{
/**
* @param array<string> $items Menu items (e.g. ['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe'])
*/
public function __construct(
private array $items
) {
}
/**
* Render the menu bar at the top of the screen
*/
public function render(ConsoleOutputInterface $output, ?string $activeMenu = null, int $screenWidth = 80): void
{
$menuLine = '';
$separator = ' | ';
foreach ($this->items as $index => $item) {
if ($index > 0) {
$menuLine .= $separator;
}
$isActive = $item === $activeMenu;
$color = $isActive ? ConsoleColor::BRIGHT_WHITE : ConsoleColor::WHITE;
$prefix = $isActive ? '▶ ' : ' ';
$menuLine .= "{$prefix}{$item}";
}
// Pad to screen width
$padding = max(0, $screenWidth - mb_strlen($menuLine));
$menuLine .= str_repeat(' ', $padding);
$output->writeLine($menuLine, ConsoleColor::BRIGHT_WHITE);
$output->writeLine(str_repeat('═', $screenWidth), ConsoleColor::GRAY);
}
/**
* Check if a mouse click is within the menu bar area
*/
public function isClickInMenuArea(int $x, int $y): bool
{
// Menu bar is always at y=1 (first line)
return $y === 1 && $x >= 1;
}
/**
* Get menu item at click position
*/
public function getItemAtPosition(int $x): ?string
{
// Simple calculation: approximate position based on item lengths
// This is a simplified version - in a real implementation, you'd track exact positions
$currentPos = 3; // Start after initial padding
foreach ($this->items as $item) {
$itemLength = mb_strlen($item) + 4; // Include prefix and spacing
if ($x >= $currentPos && $x < $currentPos + $itemLength) {
return $item;
}
$currentPos += $itemLength + 3; // Add separator length
}
return null;
}
/**
* Get hotkey for a menu item (first letter)
*/
public function getHotkeyForItem(string $item): ?string
{
$firstChar = mb_strtoupper(mb_substr($item, 0, 1));
if ($firstChar === '') {
return null;
}
return $firstChar;
}
/**
* Find menu item by hotkey
*/
public function getItemByHotkey(string $hotkey): ?string
{
$hotkeyUpper = mb_strtoupper($hotkey);
foreach ($this->items as $item) {
if (mb_strtoupper(mb_substr($item, 0, 1)) === $hotkeyUpper) {
return $item;
}
}
return null;
}
/**
* Get all menu items
*/
public function getItems(): array
{
return $this->items;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Components;
/**
* Mouse event value object
*/
final readonly class MouseEvent
{
public function __construct(
public int $x,
public int $y,
public int $button,
public bool $pressed,
public bool $shift = false,
public bool $ctrl = false,
public bool $alt = false
) {
}
/**
* Get human-readable button name
*/
public function getButtonName(): string
{
return match ($this->button) {
0 => 'Left',
1 => 'Middle',
2 => 'Right',
64 => 'Scroll Up',
65 => 'Scroll Down',
default => 'Unknown',
};
}
/**
* Check if this is a scroll event
*/
public function isScrollEvent(): bool
{
return in_array($this->button, [64, 65], true);
}
}

View File

@@ -16,7 +16,8 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
final readonly class TuiInputHandler
{
public function __construct(
private TuiCommandExecutor $commandExecutor
private TuiCommandExecutor $commandExecutor,
private ?MenuBar $menuBar = null
) {
}
@@ -489,4 +490,86 @@ final readonly class TuiInputHandler
HistoryTab::FAVORITES => $history->getFavorites(),
};
}
/**
* Handle mouse events
*/
public function handleMouseEvent(MouseEvent $event, TuiState $state): void
{
// Only handle button presses, not releases
if (! $event->pressed) {
return;
}
// Only update status for menu clicks, not all mouse movements
// This reduces flickering
// Handle menu bar clicks
if ($this->menuBar !== null && $this->menuBar->isClickInMenuArea($event->x, $event->y)) {
$menuItem = $this->menuBar->getItemAtPosition($event->x);
if ($menuItem !== null && $event->button === 0) {
// Left click on menu item
$state->setActiveMenu($menuItem);
$state->setStatus("Menü geöffnet: {$menuItem}");
}
}
}
/**
* Handle keyboard events (for Alt+Letter menu shortcuts)
*/
public function handleKeyEvent(KeyEvent $event, TuiState $state, CommandHistory $history): void
{
// Handle Alt+Letter shortcuts for menu
if ($event->alt && $this->menuBar !== null && strlen($event->key) === 1) {
$menuItem = $this->menuBar->getItemByHotkey($event->key);
if ($menuItem !== null) {
$state->setActiveMenu($menuItem);
$state->setStatus("Menü geöffnet: {$menuItem}");
return;
}
}
// Convert to legacy key string format first
$keyString = $this->keyEventToString($event);
if ($keyString === '') {
return;
}
// Only update status for non-navigation keys to reduce flickering
$isNavigationKey = in_array($event->key, ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab'], true);
if (!$isNavigationKey) {
$keyDesc = $event->getDescription();
$state->setStatus("Taste: {$keyDesc}");
}
// Don't update status for navigation keys - let navigation happen without status flicker
// Process input - this handles navigation
$this->handleInput($keyString, $state, $history);
}
/**
* Convert KeyEvent to legacy key string format
*/
private function keyEventToString(KeyEvent $event): string
{
// If code is available, use it directly (contains escape sequence)
if ($event->code !== '') {
return $event->code;
}
// Otherwise map known keys to escape sequences
return match ($event->key) {
'ArrowUp' => "\033[A",
'ArrowDown' => "\033[B",
'ArrowRight' => "\033[C",
'ArrowLeft' => "\033[D",
'Enter' => "\n",
'Escape' => "\033",
'Tab' => "\t",
default => $event->key,
};
}
}

View File

@@ -8,6 +8,7 @@ use App\Framework\Console\CommandHistory;
use App\Framework\Console\ConsoleColor;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\HistoryTab;
use App\Framework\Console\Layout\TerminalSize;
use App\Framework\Console\Screen\CursorControlCode;
use App\Framework\Console\Screen\ScreenControlCode;
use App\Framework\Console\TuiView;
@@ -18,9 +19,13 @@ use App\Framework\Discovery\ValueObjects\DiscoveredAttribute;
*/
final readonly class TuiRenderer
{
private MenuBar $menuBar;
public function __construct(
private ConsoleOutputInterface $output
) {
// Initialize menu bar with default items
$this->menuBar = new MenuBar(['Datei', 'Bearbeiten', 'Ansicht', 'Hilfe']);
}
/**
@@ -29,8 +34,17 @@ final readonly class TuiRenderer
public function render(TuiState $state, CommandHistory $history): void
{
$this->clearScreen();
$this->renderHeader();
// Get terminal size for layout
$terminalSize = TerminalSize::detect();
// Render menu bar at top
$this->renderMenuBar($state, $terminalSize->width);
// Render header (or skip if menu bar replaces it)
// $this->renderHeader();
// Render main content
match ($state->getCurrentView()) {
TuiView::CATEGORIES => $this->renderCategories($state),
TuiView::COMMANDS => $this->renderCommands($state),
@@ -40,6 +54,9 @@ final readonly class TuiRenderer
TuiView::DASHBOARD => $this->renderDashboard($state),
TuiView::HELP => $this->renderHelp($state),
};
// Render status line at bottom
$this->renderStatusLine($state, $terminalSize->width);
}
/**
@@ -443,4 +460,41 @@ final readonly class TuiRenderer
return floor($diff / 86400) . "d ago";
}
/**
* Render menu bar at the top
*/
private function renderMenuBar(TuiState $state, int $screenWidth): void
{
$this->menuBar->render($this->output, $state->getActiveMenu(), $screenWidth);
}
/**
* Render status line at the bottom
*/
private function renderStatusLine(TuiState $state, int $screenWidth): void
{
$statusText = $state->getStatus();
// Default status if empty
if ($statusText === '') {
$statusText = 'Bereit';
}
// Move cursor to last line
$terminalSize = TerminalSize::detect();
$this->output->write(CursorControlCode::POSITION->format($terminalSize->height, 1));
// Render status line with separator
$this->output->writeLine(str_repeat('─', $screenWidth), ConsoleColor::GRAY);
$this->output->writeLine($statusText, ConsoleColor::BRIGHT_BLUE);
}
/**
* Get menu bar instance
*/
public function getMenuBar(): MenuBar
{
return $this->menuBar;
}
}

View File

@@ -39,6 +39,11 @@ final class TuiState
private bool $formMode = false;
// Menu and status line
private string $statusText = '';
private ?string $activeMenu = null;
public function __construct()
{
}
@@ -318,4 +323,26 @@ final class TuiState
$this->showWorkflows = false;
$this->workflowProgress = [];
}
// Status line
public function getStatus(): string
{
return $this->statusText;
}
public function setStatus(string $text): void
{
$this->statusText = $text;
}
// Active menu
public function getActiveMenu(): ?string
{
return $this->activeMenu;
}
public function setActiveMenu(?string $menu): void
{
$this->activeMenu = $menu;
}
}

View File

@@ -269,7 +269,10 @@ final class ConsoleApplication
$this->output->writeWindowTitle("{$this->scriptName} - {$commandName}");
// Execute command via registry
return $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
$result = $this->commandRegistry->executeCommand($commandName, $arguments, $this->output);
// Handle ConsoleResult (new) or ExitCode (legacy)
return $this->processCommandResult($result);
} catch (CommandNotFoundException $e) {
return $this->errorHandler->handleCommandNotFound($commandName, $this->output);
@@ -291,6 +294,42 @@ final class ConsoleApplication
}
}
/**
* Process command result - supports both ConsoleResult and ExitCode
*/
private function processCommandResult(mixed $result): ExitCode
{
// New ConsoleResult pattern
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
// Render result to output
$result->render($this->output);
// Return exit code from result
return $result->exitCode;
}
// Legacy ExitCode pattern
if ($result instanceof ExitCode) {
return $result;
}
// Legacy int pattern (for backwards compatibility)
if (is_int($result)) {
return ExitCode::from($result);
}
// Invalid return type - log warning and return error
if ($this->container->has(Logger::class)) {
$logger = $this->container->get(Logger::class);
$logger->warning('Command returned invalid result type', LogContext::withData([
'result_type' => get_debug_type($result),
'component' => 'ConsoleApplication',
]));
}
return ExitCode::GENERAL_ERROR;
}
private function showCommandUsage(string $commandName): void
{
try {
@@ -527,6 +566,7 @@ final class ConsoleApplication
// Create TUI components
$state = new TuiState();
$renderer = new TuiRenderer($this->output);
$menuBar = $renderer->getMenuBar();
$commandExecutor = new TuiCommandExecutor(
$this->output,
$this->commandRegistry,
@@ -537,7 +577,7 @@ final class ConsoleApplication
new CommandHelpGenerator(new ParameterInspector()),
$this->scriptName
);
$inputHandler = new TuiInputHandler($commandExecutor);
$inputHandler = new TuiInputHandler($commandExecutor, $menuBar);
// Erstelle TUI Instanz
$tui = new ConsoleTUI(

View File

@@ -51,6 +51,12 @@ final readonly class ConsoleOutput implements ConsoleOutputInterface
*/
public function writeWindowTitle(string $title, int $mode = 0): void
{
// Only emit terminal escape sequences when output goes to a real terminal
// Skip for pipes, redirects, MCP mode, Claude Code execution, and any programmatic use
if (!$this->isTerminal()) {
return;
}
$this->writeRaw("\033]$mode;{$title}\007");
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Composite Result - Combines multiple results
*
* Allows combining multiple console results into a single result.
* Useful for complex commands that need to display multiple sections.
*
* Exit code follows "worst wins" strategy:
* - If any result has FAILURE, composite has FAILURE
* - Otherwise SUCCESS
*
* Example:
* return new CompositeResult(
* TextResult::info("Starting process..."),
* TableResult::create(['Step', 'Status'], $steps),
* TextResult::success("Process completed!")
* );
*/
final readonly class CompositeResult implements ConsoleResult
{
/** @var array<ConsoleResult> */
private array $results;
public readonly ExitCode $exitCode;
public readonly array $data;
/**
* Create composite result with variadic constructor
*/
public function __construct(ConsoleResult ...$results)
{
$this->results = $results;
$this->exitCode = $this->aggregateExitCode($results);
$this->data = [
'result_count' => count($results),
'results' => array_map(fn(ConsoleResult $r) => $r->data, $results),
'exit_code' => $this->exitCode->value,
];
}
/**
* Get all results
*
* @return array<ConsoleResult>
*/
public function getResults(): array
{
return $this->results;
}
/**
* Render all results sequentially
*/
public function render(ConsoleOutputInterface $output): void
{
foreach ($this->results as $result) {
$result->render($output);
}
}
/**
* Aggregate exit codes - "worst wins" strategy
*
* @param array<ConsoleResult> $results
*/
private function aggregateExitCode(array $results): ExitCode
{
foreach ($results as $result) {
if ($result->exitCode === ExitCode::FAILURE) {
return ExitCode::FAILURE;
}
}
return ExitCode::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Console Result Interface
*
* Represents the result of a console command execution.
* Similar to HTTP ActionResult, but for console output.
*
* Results are value objects that encapsulate:
* - Exit code (success/failure)
* - Rendering logic
* - Metadata for testing/introspection
*/
interface ConsoleResult
{
/**
* Exit code for this result
*
* Determines the process exit code (0 = success, >0 = failure)
*/
public ExitCode $exitCode { get; }
/**
* Metadata for testing/introspection
*
* Returns array with result data that can be inspected in tests.
* Useful for asserting on result contents without rendering.
*
* @return array<string, mixed>
*/
public array $data { get; }
/**
* Render result to console output
*
* Implementations should write to the provided output interface.
*/
public function render(ConsoleOutputInterface $output): void;
}

View File

@@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
/**
* Console Result Builder - Fluent API
*
* Provides a convenient fluent interface for building complex console results.
* Automatically creates CompositeResult when multiple components are added.
*
* Example:
* return ConsoleResultBuilder::create()
* ->addText('System Report', ConsoleStyle::SUCCESS)
* ->addDivider()
* ->addTable(['Metric', 'Value'], [
* ['CPU', '45%'],
* ['Memory', '2.3GB']
* ])
* ->addNewLine()
* ->addText('Generated: ' . date('Y-m-d H:i:s'))
* ->withExitCode(ExitCode::SUCCESS)
* ->build();
*/
final class ConsoleResultBuilder
{
/** @var array<ConsoleResult> */
private array $results = [];
private ExitCode $exitCode = ExitCode::SUCCESS;
private function __construct()
{
}
/**
* Create new builder instance
*/
public static function create(): self
{
return new self();
}
/**
* Add text result
*/
public function addText(
string $message,
ConsoleStyle $style = ConsoleStyle::INFO
): self {
$this->results[] = new TextResult($message, $style, $this->exitCode);
return $this;
}
/**
* Add success text
*/
public function addSuccess(string $message): self
{
$this->results[] = TextResult::success($message);
return $this;
}
/**
* Add error text
*/
public function addError(string $message): self
{
$this->results[] = TextResult::error($message);
$this->exitCode = ExitCode::FAILURE;
return $this;
}
/**
* Add warning text
*/
public function addWarning(string $message): self
{
$this->results[] = TextResult::warning($message);
return $this;
}
/**
* Add info text
*/
public function addInfo(string $message): self
{
$this->results[] = TextResult::info($message);
return $this;
}
/**
* Add plain text
*/
public function addPlain(string $message): self
{
$this->results[] = TextResult::plain($message);
return $this;
}
/**
* Add table result
*
* @param array<string> $headers
* @param array<array<string>> $rows
*/
public function addTable(
array $headers,
array $rows,
?string $title = null
): self {
$this->results[] = TableResult::create($headers, $rows, $title, $this->exitCode);
return $this;
}
/**
* Add divider (horizontal line)
*/
public function addDivider(string $char = '─', int $length = 80): self
{
$this->results[] = TextResult::plain(str_repeat($char, $length));
return $this;
}
/**
* Add empty line
*/
public function addNewLine(int $count = 1): self
{
for ($i = 0; $i < $count; $i++) {
$this->results[] = TextResult::plain('');
}
return $this;
}
/**
* Add existing result
*/
public function addResult(ConsoleResult $result): self
{
$this->results[] = $result;
return $this;
}
/**
* Set exit code for builder
*
* Note: Individual results may override this
*/
public function withExitCode(ExitCode $exitCode): self
{
$this->exitCode = $exitCode;
return $this;
}
/**
* Build final result
*
* Returns single result if only one component, CompositeResult otherwise
*/
public function build(): ConsoleResult
{
if (empty($this->results)) {
// Empty builder - return success with no output
return TextResult::plain('');
}
if (count($this->results) === 1) {
// Single result - return directly
return $this->results[0];
}
// Multiple results - wrap in CompositeResult
return new CompositeResult(...$this->results);
}
/**
* Convenience: Build and render immediately
*/
public function render(\App\Framework\Console\ConsoleOutputInterface $output): void
{
$this->build()->render($output);
}
}

View File

@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\Components\Table;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ExitCode;
/**
* Table Result - Tabular data output
*
* Displays data in a formatted table with headers and rows.
* Integrates with the existing Table component.
*
* Example:
* return TableResult::create(
* headers: ['ID', 'Name', 'Email'],
* rows: [['1', 'John', 'john@example.com']],
* title: 'Users'
* );
*/
final readonly class TableResult implements ConsoleResult
{
public readonly array $data;
/**
* Create table result
*
* @param array<string> $headers Table headers
* @param array<array<string>> $rows Table rows
* @param string|null $title Optional table title
* @param ExitCode $exitCode Exit code (default: SUCCESS)
*/
public function __construct(
public readonly array $headers,
public readonly array $rows,
public readonly ?string $title = null,
public readonly ExitCode $exitCode = ExitCode::SUCCESS,
) {
$this->data = [
'headers' => $this->headers,
'rows' => $this->rows,
'title' => $this->title,
'row_count' => count($this->rows),
'exit_code' => $this->exitCode->value,
];
}
/**
* Create table result (convenience factory)
*
* @param array<string> $headers
* @param array<array<string>> $rows
*/
public static function create(
array $headers,
array $rows,
?string $title = null,
ExitCode $exitCode = ExitCode::SUCCESS
): self {
return new self($headers, $rows, $title, $exitCode);
}
/**
* Create empty table with headers only
*
* @param array<string> $headers
*/
public static function empty(array $headers, ?string $title = null): self
{
return new self($headers, [], $title);
}
/**
* Render table to console output
*/
public function render(ConsoleOutputInterface $output): void
{
$table = new Table($output);
if ($this->title !== null) {
$table->setTitle($this->title);
}
$table->setHeaders($this->headers);
$table->setRows($this->rows);
$table->render();
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Result;
use App\Framework\Console\ConsoleOutputInterface;
use App\Framework\Console\ConsoleStyle;
use App\Framework\Console\ExitCode;
/**
* Text Result - Simple text output with styling
*
* The most basic console result type for displaying text messages.
* Supports different styles (success, error, warning, info) via factory methods.
*
* Example:
* return TextResult::success("User created successfully!");
* return TextResult::error("Failed to connect to database");
*/
final readonly class TextResult implements ConsoleResult
{
public readonly array $data;
/**
* Create text result
*
* Use factory methods (success/error/warning/info) for convenience.
*/
public function __construct(
public readonly string $message,
public readonly ConsoleStyle $style = ConsoleStyle::INFO,
public readonly ExitCode $exitCode = ExitCode::SUCCESS,
) {
$this->data = [
'message' => $this->message,
'style' => $this->style->value,
'exit_code' => $this->exitCode->value,
];
}
/**
* Create success result (green text, exit code 0)
*/
public static function success(string $message): self
{
return new self($message, ConsoleStyle::SUCCESS, ExitCode::SUCCESS);
}
/**
* Create error result (red text, exit code 1)
*/
public static function error(string $message): self
{
return new self($message, ConsoleStyle::ERROR, ExitCode::FAILURE);
}
/**
* Create warning result (yellow text, exit code 0)
*/
public static function warning(string $message): self
{
return new self($message, ConsoleStyle::WARNING, ExitCode::SUCCESS);
}
/**
* Create info result (blue text, exit code 0)
*/
public static function info(string $message): self
{
return new self($message, ConsoleStyle::INFO, ExitCode::SUCCESS);
}
/**
* Create plain result (no styling, exit code 0)
*/
public static function plain(string $message): self
{
return new self($message, ConsoleStyle::NONE, ExitCode::SUCCESS);
}
/**
* Render text to console output
*/
public function render(ConsoleOutputInterface $output): void
{
$output->writeLine($this->message, $this->style);
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\Console\Screen;
/**
* Mouse control codes for ANSI terminals (SGR mouse reports)
*/
enum MouseControlCode: string
{
// Enable/disable mouse reporting
case ENABLE_ALL = '?1000h'; // Enable mouse tracking
case DISABLE_ALL = '?1000l'; // Disable mouse tracking
case ENABLE_SGR = '?1006h'; // Enable SGR (Sixel Graphics Raster) mouse reports
case DISABLE_SGR = '?1006l'; // Disable SGR mouse reports
/**
* Format the mouse control code as ANSI sequence
*/
public function format(): string
{
return "\033[{$this->value}";
}
}